cursor-opencode-provider 0.6.8 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +51 -181
  2. package/dist/compaction-marker.d.ts +3 -2
  3. package/dist/compaction-marker.js +3 -2
  4. package/dist/host-agent-mode.d.ts +32 -0
  5. package/dist/host-agent-mode.js +92 -0
  6. package/dist/language-model.d.ts +15 -1
  7. package/dist/language-model.js +190 -36
  8. package/dist/models.d.ts +12 -1
  9. package/dist/models.js +16 -6
  10. package/dist/opencode2/catalog.d.ts +46 -11
  11. package/dist/opencode2/catalog.js +60 -45
  12. package/dist/opencode2/todo-tools.d.ts +64 -0
  13. package/dist/opencode2/todo-tools.js +165 -0
  14. package/dist/opencode2/types.d.ts +138 -83
  15. package/dist/opencode2/types.js +8 -10
  16. package/dist/plan-execution-kickoff.d.ts +2 -0
  17. package/dist/plan-execution-kickoff.js +25 -1
  18. package/dist/plugin-opencode2.d.ts +4 -1
  19. package/dist/plugin-opencode2.js +335 -154
  20. package/dist/plugin.js +5 -1
  21. package/dist/protocol/conversation-persistence.d.ts +2 -0
  22. package/dist/protocol/conversation-persistence.js +19 -1
  23. package/dist/protocol/conversation-state.d.ts +4 -0
  24. package/dist/protocol/conversation-state.js +4 -0
  25. package/dist/protocol/switch-mode.d.ts +5 -4
  26. package/dist/protocol/switch-mode.js +12 -4
  27. package/dist/protocol/tool-call-bridge.js +1 -1
  28. package/dist/protocol/tools.d.ts +36 -11
  29. package/dist/protocol/tools.js +747 -117
  30. package/dist/protocol/workspace-grounding.d.ts +17 -1
  31. package/dist/protocol/workspace-grounding.js +34 -5
  32. package/dist/session.d.ts +7 -1
  33. package/dist/shared.d.ts +3 -1
  34. package/dist/shared.js +3 -1
  35. package/dist/shell-timeout.d.ts +13 -0
  36. package/dist/shell-timeout.js +38 -0
  37. package/dist/todo-store.d.ts +29 -0
  38. package/dist/todo-store.js +78 -0
  39. package/dist/web-tools.d.ts +13 -0
  40. package/dist/web-tools.js +83 -5
  41. package/package.json +6 -3
@@ -13,6 +13,67 @@ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-
13
13
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
14
14
  // field number for every exec variant, so this is also the result field.
15
15
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
16
+ export const OPENCODE_1_TOOL_DIALECT = {
17
+ filePathKey: "filePath",
18
+ shellTool: "bash",
19
+ };
20
+ function jsonSchemaProperties(schema) {
21
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
22
+ return undefined;
23
+ const obj = schema;
24
+ if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
25
+ return obj.properties;
26
+ }
27
+ const nested = obj.jsonSchema;
28
+ 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;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ /** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
37
+ export function opencodePathArg(args) {
38
+ if (!args)
39
+ return undefined;
40
+ return str(args.filePath) ?? str(args.path) ?? str(args.file_path);
41
+ }
42
+ /**
43
+ * Infer the host tool dialect from advertised AI SDK schemas.
44
+ * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
45
+ */
46
+ export function hostToolDialectFromTools(tools) {
47
+ let filePathKey;
48
+ for (const name of ["read", "write", "edit"]) {
49
+ const tool = tools.find((candidate) => candidate.name === name);
50
+ const props = jsonSchemaProperties(tool?.inputSchema);
51
+ if (!props)
52
+ continue;
53
+ if ("path" in props && !("filePath" in props)) {
54
+ filePathKey = "path";
55
+ break;
56
+ }
57
+ if ("filePath" in props) {
58
+ filePathKey = "filePath";
59
+ break;
60
+ }
61
+ }
62
+ 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";
64
+ if (!filePathKey) {
65
+ // OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
66
+ filePathKey = shellTool === "shell" ? "path" : "filePath";
67
+ }
68
+ return { filePathKey, shellTool };
69
+ }
70
+ function assignHostFilePath(args, filePath, dialect) {
71
+ delete args.filePath;
72
+ delete args.path;
73
+ delete args.file_path;
74
+ if (filePath)
75
+ args[dialect.filePathKey] = filePath;
76
+ }
16
77
  export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
17
78
  export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
18
79
  export const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
@@ -171,15 +232,17 @@ export function resolveCustomWebToolAlias(toolName, aliases) {
171
232
  export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
172
233
  if (tools.length === 0)
173
234
  return [];
174
- const order = [];
175
235
  const byServer = new Map();
176
- for (const t of tools) {
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))) {
177
241
  const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
178
242
  let list = byServer.get(id.server);
179
243
  if (!list) {
180
244
  list = [];
181
245
  byServer.set(id.server, list);
182
- order.push(id.server);
183
246
  }
184
247
  list.push({
185
248
  tool_name: t.sourceName ? t.name : id.toolName,
@@ -187,7 +250,14 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
187
250
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
188
251
  });
189
252
  }
190
- return order.map((server) => ({
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) => ({
191
261
  server_name: server,
192
262
  server_identifier: server,
193
263
  tools: byServer.get(server),
@@ -262,6 +332,7 @@ const opencodeToolToCursor = {
262
332
  write: "write_args",
263
333
  grep: "grep_args",
264
334
  bash: "shell_stream_args",
335
+ shell: "shell_stream_args",
265
336
  task: "subagent_args",
266
337
  mcp: "mcp_args",
267
338
  };
@@ -348,33 +419,54 @@ const CURSOR_SUBAGENT_TYPE_TO_OPENCODE = {
348
419
  security_review: "explore",
349
420
  };
350
421
  const SUBAGENT_CATALOG_MARKER = "Available agent types and the tools they have access to:";
422
+ const SUBAGENT_INLINE_MARKER = "Available subagents:";
351
423
  function parseSubagentDescriptionCatalog(description) {
352
424
  if (!description)
353
425
  return { found: false, agents: [] };
354
426
  const marker = description.indexOf(SUBAGENT_CATALOG_MARKER);
355
- if (marker < 0)
427
+ if (marker >= 0) {
428
+ const agents = [];
429
+ const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
430
+ let started = false;
431
+ for (const line of lines) {
432
+ const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
433
+ if (!match) {
434
+ if (started && line.trim())
435
+ break;
436
+ continue;
437
+ }
438
+ started = true;
439
+ const name = match[1].trim().replace(/^`|`$/g, "");
440
+ if (!name)
441
+ continue;
442
+ const agentDescription = match[2].trim();
443
+ agents.push({
444
+ name,
445
+ ...(agentDescription ? { description: agentDescription } : {}),
446
+ });
447
+ }
448
+ return { found: true, agents };
449
+ }
450
+ // OpenCode 2 `subagent` inlines the list after "Available subagents:".
451
+ const inlineAt = description.indexOf(SUBAGENT_INLINE_MARKER);
452
+ if (inlineAt < 0)
356
453
  return { found: false, agents: [] };
454
+ const rest = description.slice(inlineAt + SUBAGENT_INLINE_MARKER.length);
357
455
  const agents = [];
358
- const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
359
- let started = false;
360
- for (const line of lines) {
361
- const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
362
- if (!match) {
363
- if (started && line.trim())
364
- break;
365
- continue;
366
- }
367
- started = true;
368
- const name = match[1].trim().replace(/^`|`$/g, "");
369
- if (!name)
370
- continue;
371
- const agentDescription = match[2].trim();
456
+ const matches = rest.matchAll(/-\s+([A-Za-z0-9_-]+):\s*/g);
457
+ const hits = [...matches];
458
+ for (let i = 0; i < hits.length; i++) {
459
+ const hit = hits[i];
460
+ const name = hit[1];
461
+ const start = (hit.index ?? 0) + hit[0].length;
462
+ const end = i + 1 < hits.length ? (hits[i + 1].index ?? rest.length) : rest.length;
463
+ const agentDescription = rest.slice(start, end).trim();
372
464
  agents.push({
373
465
  name,
374
466
  ...(agentDescription ? { description: agentDescription } : {}),
375
467
  });
376
468
  }
377
- return { found: true, agents };
469
+ return { found: agents.length > 0, agents };
378
470
  }
379
471
  function subagentTypeEnumValues(schema) {
380
472
  const out = new Set();
@@ -391,7 +483,8 @@ function subagentTypeEnumValues(schema) {
391
483
  return;
392
484
  }
393
485
  const record = value;
394
- if (propertyName === "subagent_type" && Array.isArray(record.enum)) {
486
+ if ((propertyName === "subagent_type" || propertyName === "agent")
487
+ && Array.isArray(record.enum)) {
395
488
  for (const item of record.enum) {
396
489
  if (typeof item === "string" && item)
397
490
  out.add(item);
@@ -405,16 +498,17 @@ function subagentTypeEnumValues(schema) {
405
498
  }
406
499
  /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
407
500
  export function extractHostSubagentCatalog(tools) {
408
- const executorTool = tools.find((tool) => tool.name === "task");
501
+ const executorTool = tools.find((tool) => tool.name === "task")
502
+ ?? tools.find((tool) => tool.name === "subagent");
409
503
  if (!executorTool)
410
504
  return { agents: [], complete: true };
411
505
  const described = parseSubagentDescriptionCatalog(executorTool.description);
412
506
  const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
413
507
  const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
414
- // A structured subagent_type enum is stricter than prose, so it is authoritative.
508
+ // A structured subagent_type/agent enum is stricter than prose, so it is authoritative.
415
509
  const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
416
510
  return {
417
- executor: "task",
511
+ executor: executorTool.name === "subagent" ? "subagent" : "task",
418
512
  agents: [...names].map((name) => ({
419
513
  name,
420
514
  ...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
@@ -474,13 +568,19 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
474
568
  if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
475
569
  return;
476
570
  const advertised = new Set(advertisedToolNames);
477
- if (!advertised.has("task"))
571
+ const executor = advertised.has("task")
572
+ ? "task"
573
+ : advertised.has("subagent")
574
+ ? "subagent"
575
+ : undefined;
576
+ if (!executor)
478
577
  return;
479
- const executor = "task";
480
578
  const description = str(parsed.args.description) ?? "";
481
579
  const prompt = str(parsed.args.prompt) ?? "";
482
580
  const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
483
- str(parsed.args.subagent_type) ?? "";
581
+ str(parsed.args.subagent_type) ??
582
+ str(parsed.args.agent) ??
583
+ "";
484
584
  const subagentType = resolveCursorSubagentType(cursorSubagentType, catalog);
485
585
  if (!subagentType) {
486
586
  const available = catalog?.agents.map((agent) => agent.name).join(", ") || "none";
@@ -489,15 +589,23 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
489
589
  `Available subagents: ${available}.`;
490
590
  return;
491
591
  }
492
- const resumeAgentId = str(parsed.args.task_id);
592
+ const resumeAgentId = str(parsed.args.task_id) ?? str(parsed.args.sessionID);
493
593
  parsed.toolName = executor;
494
- parsed.args = {
495
- description,
496
- prompt,
497
- subagent_type: subagentType,
498
- ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
499
- ...(parsed.args.background === true ? { background: true } : {}),
500
- };
594
+ parsed.args = executor === "subagent"
595
+ ? {
596
+ agent: subagentType,
597
+ description,
598
+ prompt,
599
+ ...(resumeAgentId ? { sessionID: resumeAgentId } : {}),
600
+ ...(parsed.args.background === true ? { background: true } : {}),
601
+ }
602
+ : {
603
+ description,
604
+ prompt,
605
+ subagent_type: subagentType,
606
+ ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
607
+ ...(parsed.args.background === true ? { background: true } : {}),
608
+ };
501
609
  }
502
610
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
503
611
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
@@ -625,7 +733,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
625
733
  const advertised = new Set(advertisedToolNames);
626
734
  if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
627
735
  return false;
628
- const filePath = str(parsed.args.filePath);
736
+ const filePath = opencodePathArg(parsed.args);
629
737
  const content = stringValue(parsed.args.content);
630
738
  if (!filePath || content === undefined || !editPath)
631
739
  return false;
@@ -651,8 +759,11 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
651
759
  if (!replacement)
652
760
  return false;
653
761
  parsed.toolName = "edit";
762
+ const filePathKey = typeof parsed.args.path === "string" && typeof parsed.args.filePath !== "string"
763
+ ? "path"
764
+ : "filePath";
654
765
  parsed.args = {
655
- filePath,
766
+ [filePathKey]: filePath,
656
767
  oldString: replacement.oldString,
657
768
  newString: replacement.newString,
658
769
  };
@@ -687,7 +798,7 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
687
798
  if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
688
799
  return;
689
800
  const requested = parsed.toolName;
690
- const filePath = str(parsed.args.filePath);
801
+ const filePath = opencodePathArg(parsed.args);
691
802
  const refuse = (reason) => {
692
803
  parsed.toolName = APPLY_PATCH_TOOL;
693
804
  parsed.args = {};
@@ -749,6 +860,18 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
749
860
  // the requested path for the typed Cursor result.
750
861
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
751
862
  }
863
+ function readRequestResultMetadata(raw, mappedArgs) {
864
+ const requestedPath = str(raw.path)
865
+ ?? str(raw.filePath)
866
+ ?? str(raw.file_path)
867
+ ?? opencodePathArg(mappedArgs)
868
+ ?? "";
869
+ return {
870
+ path: requestedPath,
871
+ ...(typeof mappedArgs.offset === "number" ? { offset: mappedArgs.offset } : {}),
872
+ ...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
873
+ };
874
+ }
752
875
  /**
753
876
  * A partial-read notice is for Cursor's model, never for a file. Refuse only
754
877
  * whole-file mutation forms that echo it. A targeted edit or Update File patch
@@ -775,18 +898,19 @@ export function rejectPartialReadMutation(parsed) {
775
898
  || !content.includes("[Partial read:")
776
899
  || !content.includes("It is NOT the complete file."))
777
900
  return;
778
- const filePath = typeof parsed.args.filePath === "string"
779
- ? parsed.args.filePath
780
- : "the target file";
901
+ const filePath = opencodePathArg(parsed.args) ?? "the target file";
781
902
  const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
903
+ const longLine = content.includes("Use a byte-preserving read method");
782
904
  parsed.localError =
783
905
  `NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
784
906
  "because it contains the provider's partial-read notice. Do not retry the same mutation. " +
785
907
  (nextOffset
786
908
  ? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
787
- : "Read the remaining file ranges, then use a targeted edit or Update File patch.");
909
+ : longLine
910
+ ? "Inspect the full line with a byte-preserving read method, then use a targeted edit or Update File patch."
911
+ : "Read the remaining file ranges, then use a targeted edit or Update File patch.");
788
912
  }
789
- export function parseExecServerMessage(msg) {
913
+ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
790
914
  const id = msg.id;
791
915
  if (id === undefined)
792
916
  return undefined;
@@ -823,7 +947,7 @@ export function parseExecServerMessage(msg) {
823
947
  return {
824
948
  id,
825
949
  execId,
826
- toolName: "bash",
950
+ toolName: dialect.shellTool,
827
951
  args,
828
952
  resultField,
829
953
  resultMetadata: {
@@ -877,13 +1001,17 @@ export function parseExecServerMessage(msg) {
877
1001
  // tool name (Cursor's model may have shortened "opencode-read" → "read") and
878
1002
  // decode the argument map back into JSON for opencode to execute.
879
1003
  const m = msg.mcp_args ?? {};
880
- const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), decodeMcpArgs(m.args), "mcp_args");
1004
+ const rawArgs = decodeMcpArgs(m.args);
1005
+ const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), rawArgs, "mcp_args", dialect);
881
1006
  return {
882
1007
  id,
883
1008
  execId,
884
1009
  toolName: mapped.toolName,
885
1010
  args: mapped.args,
886
1011
  resultField,
1012
+ ...(mapped.toolName === "read"
1013
+ ? { resultMetadata: readRequestResultMetadata(rawArgs, mapped.args) }
1014
+ : {}),
887
1015
  };
888
1016
  }
889
1017
  if (execVariant === "pi_edit_args") {
@@ -896,8 +1024,7 @@ export function parseExecServerMessage(msg) {
896
1024
  const oldString = replacement ? stringValue(replacement.old_text) : undefined;
897
1025
  const newString = replacement ? stringValue(replacement.new_text) : undefined;
898
1026
  const args = {};
899
- if (path)
900
- args.filePath = path;
1027
+ assignHostFilePath(args, path, dialect);
901
1028
  if (oldString !== undefined)
902
1029
  args.oldString = oldString;
903
1030
  if (newString !== undefined)
@@ -916,16 +1043,12 @@ export function parseExecServerMessage(msg) {
916
1043
  const toolName = cursorToolToOpencode[execVariant];
917
1044
  if (!toolName)
918
1045
  return undefined;
919
- const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
1046
+ const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant, dialect);
920
1047
  const rawArgs = msg[execVariant] ?? {};
921
1048
  const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
922
1049
  ? shellStreamResultMetadata(rawArgs)
923
- : execVariant === "read_args"
924
- ? {
925
- path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
926
- ...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
927
- ...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
928
- }
1050
+ : execVariant === "read_args" || execVariant === "pi_read_args"
1051
+ ? readRequestResultMetadata(rawArgs, mapped.args)
929
1052
  : undefined;
930
1053
  if (resultMetadata
931
1054
  && resultMetadata.timeout_behavior !== 2
@@ -980,7 +1103,7 @@ function shellStreamResultMetadata(raw) {
980
1103
  * Without this, OpenCode rejects calls with InvalidArgumentsError
981
1104
  * (e.g. `path` instead of `filePath`, `file_text` instead of `content`).
982
1105
  */
983
- export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1106
+ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OPENCODE_1_TOOL_DIALECT) {
984
1107
  const cleaned = {};
985
1108
  for (const [k, v] of Object.entries(raw)) {
986
1109
  if (v === undefined || v === null)
@@ -996,7 +1119,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
996
1119
  // Native ls_args → OpenCode read (directory listing via read).
997
1120
  if (execVariant === "ls_args") {
998
1121
  const filePath = str(cleaned.path) ?? str(cleaned.filePath);
999
- return { toolName: "read", args: filePath ? { filePath } : {} };
1122
+ return { toolName: "read", args: filePath ? { [dialect.filePathKey]: filePath } : {} };
1000
1123
  }
1001
1124
  // F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
1002
1125
  // we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
@@ -1004,7 +1127,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1004
1127
  if (execVariant === "delete_args") {
1005
1128
  const target = str(cleaned.path) ?? str(cleaned.filePath);
1006
1129
  return {
1007
- toolName: "bash",
1130
+ toolName: dialect.shellTool,
1008
1131
  args: target ? { command: `rm -f -- ${shellQuote(target)}` } : { command: "true" },
1009
1132
  };
1010
1133
  }
@@ -1012,8 +1135,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1012
1135
  case "read": {
1013
1136
  const args = {};
1014
1137
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1015
- if (filePath)
1016
- args.filePath = filePath;
1138
+ assignHostFilePath(args, filePath, dialect);
1017
1139
  // Cursor often sends offset=0/limit=0 as protobuf defaults. OpenCode's
1018
1140
  // read treats limit=0 as "read zero lines" (empty content) — omit zeros.
1019
1141
  const offset = num(cleaned.offset);
@@ -1027,8 +1149,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1027
1149
  case "write": {
1028
1150
  const args = {};
1029
1151
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1030
- if (filePath)
1031
- args.filePath = filePath;
1152
+ assignHostFilePath(args, filePath, dialect);
1032
1153
  // Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
1033
1154
  // non-empty and only falls back to `file_text`; mirror that order so a
1034
1155
  // byte-encoded write is not silently seen as empty content.
@@ -1060,8 +1181,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1060
1181
  }
1061
1182
  const args = {};
1062
1183
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1063
- if (filePath)
1064
- args.filePath = filePath;
1184
+ assignHostFilePath(args, filePath, dialect);
1065
1185
  const oldString = stringValue(cleaned.oldString) ?? stringValue(cleaned.old_string);
1066
1186
  if (oldString !== undefined)
1067
1187
  args.oldString = oldString;
@@ -1072,7 +1192,8 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1072
1192
  args.replaceAll = cleaned.replaceAll;
1073
1193
  return { toolName: "edit", args };
1074
1194
  }
1075
- case "bash": {
1195
+ case "bash":
1196
+ case "shell": {
1076
1197
  const args = {};
1077
1198
  const command = str(cleaned.command);
1078
1199
  if (command)
@@ -1083,7 +1204,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1083
1204
  const timeout = num(cleaned.timeout);
1084
1205
  if (timeout !== undefined)
1085
1206
  args.timeout = timeout;
1086
- return { toolName: "bash", args };
1207
+ return { toolName: dialect.shellTool, args };
1087
1208
  }
1088
1209
  case "grep": {
1089
1210
  const pattern = str(cleaned.pattern);
@@ -1193,12 +1314,33 @@ function num(v) {
1193
1314
  return Number(v);
1194
1315
  return undefined;
1195
1316
  }
1317
+ /**
1318
+ * Fallback OpenCode task title when Cursor's SubagentArgs has no description.
1319
+ * Prefer {@link preferCorrelatedTaskDescription} when a TaskToolCall is available —
1320
+ * that carries Cursor's real 3–5 word title. This only slices the prompt.
1321
+ */
1196
1322
  function describeSubagentTask(prompt, subagentType) {
1197
1323
  const words = prompt?.replace(/\s+/g, " ").trim().split(" ").filter(Boolean).slice(0, 5);
1198
1324
  if (words?.length)
1199
1325
  return words.join(" ");
1200
1326
  return `${subagentType || "Delegated"} task`;
1201
1327
  }
1328
+ /**
1329
+ * Replace the prompt-derived task title with Cursor's TaskToolCall description
1330
+ * when the display call is correlated via tool_call_id. SubagentArgs itself has
1331
+ * no description field, so without this OpenCode shows a cut-off first-five-words
1332
+ * slice of the prompt.
1333
+ */
1334
+ export function preferCorrelatedTaskDescription(parsed, description) {
1335
+ if (parsed.toolName !== "task" && parsed.toolName !== "subagent")
1336
+ return;
1337
+ if (parsed.resultField !== "subagent_result")
1338
+ return;
1339
+ const trimmed = description?.trim();
1340
+ if (!trimmed)
1341
+ return;
1342
+ parsed.args.description = trimmed;
1343
+ }
1202
1344
  function shellQuote(s) {
1203
1345
  return `'${s.replace(/'/g, `'\\''`)}'`;
1204
1346
  }
@@ -1288,9 +1430,11 @@ export function isUriReadTarget(requested) {
1288
1430
  */
1289
1431
  export function resolveReadTargetPath(requested, workspaceRoot) {
1290
1432
  const expanded = untildify(requested);
1291
- if (workspaceRoot && !path.isAbsolute(expanded)) {
1292
- return path.resolve(workspaceRoot, expanded);
1433
+ if (isAbsoluteToolPath(expanded)) {
1434
+ return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
1293
1435
  }
1436
+ if (workspaceRoot)
1437
+ return joinToolPath(workspaceRoot, expanded);
1294
1438
  return path.resolve(expanded);
1295
1439
  }
1296
1440
  /**
@@ -1355,6 +1499,7 @@ export function buildExecClientMessages(input) {
1355
1499
  const resultField = input.resultField || "mcp_result";
1356
1500
  const frames = [];
1357
1501
  if (resultField === "shell_stream") {
1502
+ const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
1358
1503
  // Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
1359
1504
  frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
1360
1505
  if (input.error) {
@@ -1362,8 +1507,8 @@ export function buildExecClientMessages(input) {
1362
1507
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
1363
1508
  }
1364
1509
  else {
1365
- if (input.output) {
1366
- frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
1510
+ if (stdout) {
1511
+ frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
1367
1512
  }
1368
1513
  if (input.shellOutcome?.kind === "backgrounded") {
1369
1514
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
@@ -1573,17 +1718,396 @@ export function buildUnsupportedExecDeny(input) {
1573
1718
  frames.push(buildExecStreamClose(execId));
1574
1719
  return frames;
1575
1720
  }
1721
+ /** OpenCode 2 `read` prints this when a text page does not reach EOF. */
1722
+ const OPENCODE2_READ_TRUNCATION = /^\[Output truncated\. Continue reading with offset:\s*(\d+)\]\s*$/;
1723
+ /** OpenCode 2 stops a read at 2,000 lines as well as 50 KB. */
1724
+ const OPENCODE2_READ_MAX_LINES = 2_000;
1725
+ const OPENCODE2_READ_MAX_LINE_CHARS = 2_000;
1726
+ const OPENCODE2_READ_LINE_TRUNCATION = `... (line truncated to ${OPENCODE2_READ_MAX_LINE_CHARS} chars)`;
1727
+ /**
1728
+ * `read-filesystem.ts` shortens each selected line to 2,000 UTF-16 code units.
1729
+ * One code unit encodes to at most three UTF-8 bytes. Add the 34-byte
1730
+ * `... (line truncated to 2000 chars)` suffix and one line separator when
1731
+ * proving a page was close enough for the next line not to fit.
1732
+ */
1733
+ const OPENCODE2_MAX_RENDERED_LINE_BYTES = (OPENCODE2_READ_MAX_LINE_CHARS * 3) + Buffer.byteLength(OPENCODE2_READ_LINE_TRUNCATION, "utf8") + 1;
1734
+ function normalizeToolText(output) {
1735
+ return output.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1736
+ }
1737
+ /**
1738
+ * OpenCode 2 `ReadTool.toModelContent` for a file. The whole string must match;
1739
+ * a file that merely quotes the header stays a numbered body line.
1740
+ *
1741
+ * Read file <path>, lines <start>-<end>
1742
+ * <n>: <line>
1743
+ * [Output truncated. Continue reading with offset: <next>]
1744
+ */
1745
+ function parseOpenCode2FileRead(output, resultMetadata) {
1746
+ const normalized = normalizeToolText(output).replace(/\n+$/, "");
1747
+ if (!normalized.startsWith("Read file "))
1748
+ return undefined;
1749
+ const lines = normalized.split("\n");
1750
+ const headerLine = (lines[0] ?? "").trim();
1751
+ const empty = /^Read file (.*), 0 lines$/.exec(headerLine);
1752
+ if (empty) {
1753
+ if (lines.length !== 1)
1754
+ return undefined;
1755
+ return {
1756
+ path: empty[1] ?? "",
1757
+ content: "",
1758
+ totalLines: 0,
1759
+ hostTruncated: false,
1760
+ outputCapped: false,
1761
+ truncatedLines: [],
1762
+ };
1763
+ }
1764
+ const header = /^Read file (.*), lines (\d+)-(\d+)$/.exec(headerLine);
1765
+ if (!header)
1766
+ return undefined;
1767
+ const start = Number(header[2]);
1768
+ const end = Number(header[3]);
1769
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
1770
+ return undefined;
1771
+ }
1772
+ let bodyEnd = lines.length;
1773
+ let nextOffset;
1774
+ const banner = OPENCODE2_READ_TRUNCATION.exec(lines[lines.length - 1] ?? "");
1775
+ if (banner) {
1776
+ nextOffset = Number(banner[1]);
1777
+ if (!Number.isSafeInteger(nextOffset) || nextOffset < 1)
1778
+ return undefined;
1779
+ bodyEnd -= 1;
1780
+ }
1781
+ const expected = end - start + 1;
1782
+ if (bodyEnd - 1 !== expected)
1783
+ return undefined;
1784
+ const raw = [];
1785
+ const truncatedLines = [];
1786
+ for (let index = 0; index < expected; index++) {
1787
+ const match = /^(\d+):[ \t](.*)$/.exec(lines[index + 1] ?? "");
1788
+ if (!match || Number(match[1]) !== start + index)
1789
+ return undefined;
1790
+ const value = match[2] ?? "";
1791
+ if (value.length === OPENCODE2_READ_MAX_LINE_CHARS + OPENCODE2_READ_LINE_TRUNCATION.length
1792
+ && value.endsWith(OPENCODE2_READ_LINE_TRUNCATION)) {
1793
+ truncatedLines.push(start + index);
1794
+ }
1795
+ raw.push(value);
1796
+ }
1797
+ const content = raw.join("\n");
1798
+ const hostTruncated = nextOffset !== undefined;
1799
+ const requestedLimit = num(resultMetadata?.limit);
1800
+ const effectiveLineLimit = requestedLimit !== undefined && requestedLimit > 0
1801
+ ? Math.min(requestedLimit, OPENCODE2_READ_MAX_LINES)
1802
+ : OPENCODE2_READ_MAX_LINES;
1803
+ // OpenCode 2 ends a text page only for the requested/default line limit or
1804
+ // the 50 KiB byte limit. Fewer lines than the effective line limit, together
1805
+ // with a page close enough that one maximum-size rendered line may not fit,
1806
+ // identifies the byte cap without the old ASCII-only 4 KiB guess.
1807
+ const outputCapped = hostTruncated
1808
+ && expected < effectiveLineLimit
1809
+ && Buffer.byteLength(content, "utf8") > OPENCODE_READ_MAX_BYTES - OPENCODE2_MAX_RENDERED_LINE_BYTES;
1810
+ if (hostTruncated) {
1811
+ return {
1812
+ path: header[1] ?? "",
1813
+ content,
1814
+ startLine: start,
1815
+ endLine: end,
1816
+ nextOffset,
1817
+ hostTruncated: true,
1818
+ outputCapped,
1819
+ truncatedLines,
1820
+ };
1821
+ }
1822
+ // A page that reached EOF is complete. Record the span so a non-1 offset is
1823
+ // not later mistaken for a silent cap, without flagging it truncated.
1824
+ return {
1825
+ path: header[1] ?? "",
1826
+ content,
1827
+ ...(start === 1
1828
+ ? { totalLines: end }
1829
+ : { startLine: start, endLine: end, totalLines: end }),
1830
+ hostTruncated: false,
1831
+ outputCapped: false,
1832
+ truncatedLines,
1833
+ };
1834
+ }
1835
+ /**
1836
+ * OpenCode 2 directory reads list bare entry names. Join them onto the
1837
+ * directory, then onto the workspace root, so the model can copy a real path
1838
+ * instead of inventing an absolute prefix.
1839
+ */
1840
+ function parseOpenCode2DirectoryListing(output, workspaceRoot) {
1841
+ const normalized = normalizeToolText(output).replace(/\n+$/, "");
1842
+ if (!normalized.startsWith("Read directory "))
1843
+ return undefined;
1844
+ const lines = normalized.split("\n");
1845
+ const header = /^Read directory (.*), (?:0 entries|entries (\d+)-(\d+))$/.exec((lines[0] ?? "").trim());
1846
+ if (!header) {
1847
+ trace("parseOpenCode2DirectoryListing: OpenCode 2 directory header did not match — leaving output unchanged");
1848
+ return undefined;
1849
+ }
1850
+ const requested = header[1] ?? "";
1851
+ if (!isAbsoluteToolPath(requested) && !workspaceRoot)
1852
+ return undefined;
1853
+ let bodyEnd = lines.length;
1854
+ let banner;
1855
+ if (bodyEnd > 1 && OPENCODE2_READ_TRUNCATION.test(lines[bodyEnd - 1] ?? "")) {
1856
+ banner = lines[bodyEnd - 1];
1857
+ bodyEnd -= 1;
1858
+ }
1859
+ const rawEntries = lines.slice(1, bodyEnd);
1860
+ if (header[2] === undefined) {
1861
+ if (rawEntries.length !== 0)
1862
+ return undefined;
1863
+ }
1864
+ else {
1865
+ const start = Number(header[2]);
1866
+ const end = Number(header[3]);
1867
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end - start + 1 !== rawEntries.length) {
1868
+ return undefined;
1869
+ }
1870
+ }
1871
+ const directory = resolveToolPath(requested, workspaceRoot);
1872
+ const directories = [];
1873
+ const files = [];
1874
+ const entries = rawEntries.map((entry) => {
1875
+ const resolved = resolveListedEntry(directory, entry);
1876
+ if (entry.endsWith("/") || entry.endsWith("\\"))
1877
+ directories.push(resolved);
1878
+ else
1879
+ files.push(resolved);
1880
+ return resolved;
1881
+ });
1882
+ const headerLine = header[2] === undefined
1883
+ ? `Read directory ${directory}, 0 entries`
1884
+ : `Read directory ${directory}, entries ${header[2]}-${header[3]}`;
1885
+ return {
1886
+ directory,
1887
+ entries,
1888
+ directories,
1889
+ files,
1890
+ text: [headerLine, ...entries, ...(banner ? [banner] : [])].join("\n"),
1891
+ };
1892
+ }
1893
+ /**
1894
+ * Absolute on this host, plus Windows drive and UNC paths. A posix agent must
1895
+ * not prefix `C:/…` or `\\server\…` just because `path.isAbsolute` rejects them.
1896
+ */
1897
+ function isAbsoluteToolPath(filePath) {
1898
+ return path.isAbsolute(filePath)
1899
+ || /^[A-Za-z]:[\\/]/.test(filePath)
1900
+ || filePath.startsWith("\\\\");
1901
+ }
1902
+ /** Windows drive or UNC path that `path.isAbsolute` rejects on this host. */
1903
+ function isForeignAbsoluteToolPath(filePath) {
1904
+ return isAbsoluteToolPath(filePath) && !path.isAbsolute(filePath);
1905
+ }
1906
+ /** Separator already used by a drive or UNC path. Host paths keep `path.sep`. */
1907
+ function toolPathSeparator(filePath) {
1908
+ if (path.isAbsolute(filePath))
1909
+ return path.sep;
1910
+ if (filePath.startsWith("\\\\") || (filePath.includes("\\") && !filePath.includes("/")))
1911
+ return "\\";
1912
+ return "/";
1913
+ }
1914
+ /**
1915
+ * Join `relative` onto `root`. Node's `path.resolve` treats `C:/…` and
1916
+ * `\\server\…` as relative on posix and prefixes the process cwd. Keep those
1917
+ * roots intact, including `..`, and still use `path.resolve` for host paths.
1918
+ */
1919
+ function joinToolPath(root, relative) {
1920
+ if (!isForeignAbsoluteToolPath(root))
1921
+ return path.resolve(root, relative);
1922
+ const sep = toolPathSeparator(root);
1923
+ const base = splitForeignAbsolute(root, sep);
1924
+ const parts = [...base.segments];
1925
+ for (const part of relative.split(/[\\/]+/)) {
1926
+ if (!part || part === ".")
1927
+ continue;
1928
+ if (part === "..") {
1929
+ if (parts.length > base.frozen)
1930
+ parts.pop();
1931
+ continue;
1932
+ }
1933
+ parts.push(part);
1934
+ }
1935
+ return parts.length === 0 ? base.prefix : `${base.prefix}${parts.join(sep)}`;
1936
+ }
1937
+ function splitForeignAbsolute(filePath, sep) {
1938
+ if (filePath.startsWith("\\\\")) {
1939
+ const segments = filePath.slice(2).split(/[\\/]+/).filter(Boolean);
1940
+ return { prefix: "\\\\", segments, frozen: Math.min(2, segments.length) };
1941
+ }
1942
+ const rest = filePath.slice(2).replace(/^[\\/]+/, "");
1943
+ return {
1944
+ prefix: `${filePath.slice(0, 2)}${sep}`,
1945
+ segments: rest ? rest.split(/[\\/]+/).filter(Boolean) : [],
1946
+ frozen: 0,
1947
+ };
1948
+ }
1949
+ function isRelativePathToken(token) {
1950
+ if (!token || isAbsoluteToolPath(token))
1951
+ return false;
1952
+ // A slash alone does not make arbitrary shell output a path. In particular,
1953
+ // compact JSON, quoted strings, package ids, and shell syntax must remain
1954
+ // byte-for-byte model-visible rather than being prefixed with the workspace.
1955
+ if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
1956
+ return false;
1957
+ if (token.startsWith("./")
1958
+ || token.startsWith("../")
1959
+ || token.startsWith(".\\")
1960
+ || token.startsWith("..\\"))
1961
+ return true;
1962
+ return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
1963
+ }
1964
+ function resolveListedEntry(directory, entry) {
1965
+ if (!entry || isAbsoluteToolPath(entry))
1966
+ return entry;
1967
+ if (entry === "~" || entry.startsWith("~/") || entry.startsWith("~\\"))
1968
+ return entry;
1969
+ const directoryEntry = entry.endsWith("/") || entry.endsWith("\\");
1970
+ const resolved = joinToolPath(directory, entry);
1971
+ if (!directoryEntry || resolved.endsWith("/") || resolved.endsWith("\\"))
1972
+ return resolved;
1973
+ return `${resolved}${toolPathSeparator(resolved)}`;
1974
+ }
1975
+ function resolveToolPath(filePath, workspaceRoot) {
1976
+ if (!filePath || isAbsoluteToolPath(filePath))
1977
+ return filePath;
1978
+ if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\"))
1979
+ return filePath;
1980
+ if (!workspaceRoot)
1981
+ return filePath;
1982
+ return joinToolPath(workspaceRoot, filePath);
1983
+ }
1984
+ /**
1985
+ * Rewrite OpenCode 2 grep/glob lines that are still project-relative. Absolute
1986
+ * paths, indented match previews, and prose stay untouched. Grep file headers
1987
+ * drop the trailing colon only in the structured file list, not in this text.
1988
+ */
1989
+ function groundSearchOutput(output, workspaceRoot) {
1990
+ if (!workspaceRoot)
1991
+ return output;
1992
+ 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");
2000
+ }
2001
+ function isBarePathList(output) {
2002
+ const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
2003
+ if (lines.length === 0 || lines.length > 2000)
2004
+ return false;
2005
+ return lines.every((line) => {
2006
+ if (line.startsWith("("))
2007
+ return true;
2008
+ if (/\s/.test(line) || line.includes("://"))
2009
+ return false;
2010
+ return true;
2011
+ });
2012
+ }
2013
+ function rewriteSearchPathLine(line, workspaceRoot) {
2014
+ if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
2015
+ return line;
2016
+ if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
2017
+ return line;
2018
+ const header = /^(.*):$/.exec(line);
2019
+ if (header && !header[1]?.includes("://")) {
2020
+ return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
2021
+ }
2022
+ if (line.includes("://"))
2023
+ return line;
2024
+ return resolveToolPath(line, workspaceRoot);
2025
+ }
2026
+ /**
2027
+ * Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
2028
+ * paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
2029
+ * status words alone.
2030
+ */
2031
+ function groundShellPathText(output, root) {
2032
+ if (!root || !output)
2033
+ return output;
2034
+ return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
2035
+ }
2036
+ function shellPathRoot(resultMetadata, workspaceRoot) {
2037
+ const workingDirectory = str(resultMetadata?.working_directory)?.trim();
2038
+ if (!workingDirectory)
2039
+ return workspaceRoot;
2040
+ if (isAbsoluteToolPath(workingDirectory))
2041
+ return workingDirectory;
2042
+ return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
2043
+ }
2044
+ function rewriteShellPathLine(line, root) {
2045
+ if (!line || line.startsWith(" ") || line.startsWith("\t"))
2046
+ return line;
2047
+ const trimmed = line.trimEnd();
2048
+ if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
2049
+ return line;
2050
+ }
2051
+ const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
2052
+ if (located && located[1] && isRelativePathToken(located[1])) {
2053
+ const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
2054
+ return `${resolveToolPath(located[1], root)}${suffix}`;
2055
+ }
2056
+ if (/\s/.test(trimmed))
2057
+ return line;
2058
+ const header = /^(.*):$/.exec(trimmed);
2059
+ if (header && header[1] && isRelativePathToken(header[1])) {
2060
+ return `${resolveToolPath(header[1], root)}:`;
2061
+ }
2062
+ if (isRelativePathToken(trimmed))
2063
+ return resolveToolPath(trimmed, root);
2064
+ return line;
2065
+ }
2066
+ function extractGroundedPaths(output, workspaceRoot) {
2067
+ const normalized = normalizeToolText(output);
2068
+ const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
2069
+ if (directory)
2070
+ 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") {
2073
+ const paths = [];
2074
+ for (const raw of normalized.split("\n").slice(1)) {
2075
+ const line = raw.trim();
2076
+ if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
2077
+ continue;
2078
+ }
2079
+ const header = /^(.*):$/.exec(line);
2080
+ const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
2081
+ if (!candidate || candidate.includes("://"))
2082
+ continue;
2083
+ paths.push(resolveToolPath(candidate, workspaceRoot));
2084
+ }
2085
+ return paths.slice(0, 2000);
2086
+ }
2087
+ if (isBarePathList(output)) {
2088
+ return output
2089
+ .split("\n")
2090
+ .map((line) => line.trim())
2091
+ .filter((line) => line && !line.startsWith("("))
2092
+ .map((line) => resolveToolPath(line.endsWith(":") && !line.includes("://") ? line.slice(0, -1) : line, workspaceRoot))
2093
+ .slice(0, 2000);
2094
+ }
2095
+ return extractPathLines(output).map((line) => {
2096
+ const trimmed = line.trim();
2097
+ const candidate = trimmed.endsWith(":") && !trimmed.includes("://") ? trimmed.slice(0, -1) : trimmed;
2098
+ return resolveToolPath(candidate, workspaceRoot);
2099
+ });
2100
+ }
1576
2101
  /**
1577
2102
  * Strip opencode's `read` envelope, leaving raw file content.
1578
2103
  *
1579
- * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
1580
- * envelope its own models are trained on, but Cursor's are not:
2104
+ * OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
2105
+ * models are trained on, but Cursor's are not:
1581
2106
  * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
1582
- * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
1583
- * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
1584
- * back into files (silent corruption — the write still reports success; seen
1585
- * across 8+ sessions, e.g. language-model.ts rewritten starting with
1586
- * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
2107
+ * OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
2108
+ * the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
2109
+ * Forwarding either envelope verbatim made Cursor's model treat the wrapper as
2110
+ * literal file content and write tags or line prefixes back into files.
1587
2111
  *
1588
2112
  * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
1589
2113
  *
@@ -1596,6 +2120,12 @@ export function buildUnsupportedExecDeny(input) {
1596
2120
  export function unwrapReadOutput(output) {
1597
2121
  if (typeof output !== "string" || output.length === 0)
1598
2122
  return output;
2123
+ const opencode2 = parseOpenCode2FileRead(output);
2124
+ if (opencode2)
2125
+ return opencode2.content;
2126
+ if (normalizeToolText(output).startsWith("Read file ")) {
2127
+ trace("unwrapReadOutput: OpenCode 2 read header did not match the page parser — leaving output unchanged");
2128
+ }
1599
2129
  // Require the full opencode read-envelope skeleton *before* `<content>`
1600
2130
  // (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
1601
2131
  // stray "<content>" later in tool chatter can't trigger unwrapping just
@@ -1646,34 +2176,53 @@ export function unwrapReadOutput(output) {
1646
2176
  export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
1647
2177
  // Prefer the session workspace; never advertise the host process cwd (daemon
1648
2178
  // often starts in $HOME) as the path Cursor shows the model for glob/ls.
1649
- const resultRoot = typeof workspaceRoot === "string" && workspaceRoot.trim()
1650
- ? path.resolve(workspaceRoot)
2179
+ const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
2180
+ // A posix process must not turn `C:/…` or `\\server\…` into `<cwd>/C:/…`.
2181
+ const resultRoot = trimmedRoot
2182
+ ? (isForeignAbsoluteToolPath(trimmedRoot) ? trimmedRoot : path.resolve(trimmedRoot))
1651
2183
  : undefined;
1652
2184
  switch (resultField) {
1653
2185
  case "read_result": {
1654
- const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
2186
+ const parsedFile = parseOpenCode2FileRead(output, resultMetadata);
2187
+ const listing = error ? undefined : parseOpenCode2DirectoryListing(output, resultRoot);
2188
+ const rawPath = str(resultMetadata?.path)
2189
+ ?? parsedFile?.path
2190
+ ?? listing?.directory
2191
+ ?? extractPathTag(output)
2192
+ ?? "";
2193
+ const readPath = resolveToolPath(rawPath, resultRoot);
1655
2194
  if (error)
1656
2195
  return { error: { path: readPath, error } };
1657
- // Strip opencode's <path>/<content> envelope so Cursor's model receives
1658
- // raw file content and can't echo the wrapper into subsequent writes.
2196
+ // The host may have resolved an alternate spelling after the request
2197
+ // (for example a Unicode-space filename). Use the result's own path for
2198
+ // local stat/newline recovery while preserving the requested path in the
2199
+ // Cursor result, matching the existing OpenCode 1 behavior.
2200
+ const outputPath = parsedFile?.path ?? extractPathTag(output);
2201
+ const statPath = resolveToolPath(outputPath ?? readPath, resultRoot);
2202
+ // Strip OpenCode's read envelope so Cursor's model receives raw file
2203
+ // content and can't echo the wrapper into subsequent writes.
1659
2204
  // Cursor's native read_args lands here; `mcp_args` reads land in
1660
2205
  // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1661
2206
  // native channel, so this is the path that matters in practice.
1662
- const statPath = extractPathTag(output) ?? readPath;
1663
- const outputMetadata = parseOpenCodeReadMetadata(output);
1664
- const content = restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
2207
+ const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
2208
+ const content = listing
2209
+ ? listing.text
2210
+ : restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
1665
2211
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1666
2212
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1667
2213
  // `truncated` alone is not enough: it is set here, and models still assert
1668
2214
  // the partial content is the whole file. Cursor's own executor puts the
1669
2215
  // limit marker in the output text for the same reason, so append one.
1670
- const notice = readTruncationNotice(output, resultMetadata);
2216
+ const notices = [
2217
+ readTruncationNotice(output, resultMetadata),
2218
+ readLongLineTruncationNotice(output),
2219
+ ].filter((notice) => !!notice);
1671
2220
  return {
1672
2221
  success: {
1673
- path: readPath,
1674
- content: notice ? `${content}\n\n${notice}` : content,
2222
+ path: listing?.directory || readPath,
2223
+ content: notices.length > 0 ? `${content}\n\n${notices.join("\n\n")}` : content,
1675
2224
  total_lines: totalLines,
1676
- file_size: readFileSize(statPath),
2225
+ file_size: listing ? 0 : readFileSize(statPath),
1677
2226
  truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
1678
2227
  range_applied: rangeApplied,
1679
2228
  },
@@ -1684,7 +2233,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1684
2233
  return { error: { error } };
1685
2234
  // Prefer files_with_matches: OpenCode glob/grep often returns path lists.
1686
2235
  // Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
1687
- const files = extractPathLines(output);
2236
+ const files = extractGroundedPaths(output, resultRoot);
1688
2237
  const cwd = resultRoot ?? "";
1689
2238
  return {
1690
2239
  success: {
@@ -1728,19 +2277,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1728
2277
  // Pi results carry truncation structurally (PiReadExecSuccess field 2),
1729
2278
  // which is how Cursor's own executors report a capped payload.
1730
2279
  const content = unwrapReadOutput(output);
1731
- const truncation = readTruncationMessage(output, content);
2280
+ const truncation = readTruncationMessage(output, content, resultMetadata);
1732
2281
  return { success: { output: content, ...(truncation ? { truncation } : {}) } };
1733
2282
  }
1734
2283
  case "shell_result": {
1735
2284
  const command = str(resultMetadata?.command) ?? "";
1736
2285
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2286
+ const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
1737
2287
  if (error) {
1738
2288
  return {
1739
2289
  failure: {
1740
2290
  command,
1741
2291
  working_directory: workingDirectory,
1742
2292
  exit_code: 1,
1743
- stdout: output || "",
2293
+ stdout: stdout || "",
1744
2294
  stderr: error,
1745
2295
  aborted: false,
1746
2296
  },
@@ -1761,7 +2311,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1761
2311
  command: shellOutcome.command || command,
1762
2312
  working_directory: shellOutcome.workingDirectory || workingDirectory,
1763
2313
  exit_code: 0,
1764
- stdout: output,
2314
+ stdout: stdout,
1765
2315
  shell_id: shellOutcome.shellId,
1766
2316
  pid: shellOutcome.pid,
1767
2317
  ms_to_wait: shellOutcome.msToWait,
@@ -1779,18 +2329,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1779
2329
  command,
1780
2330
  working_directory: workingDirectory,
1781
2331
  exit_code: exitCode,
1782
- stdout: output,
2332
+ stdout: stdout,
1783
2333
  },
1784
2334
  };
1785
2335
  }
1786
2336
  case "pi_bash_result":
2337
+ if (error)
2338
+ return { error: { error } };
2339
+ return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
1787
2340
  case "pi_edit_result":
2341
+ if (error)
2342
+ return { error: { error } };
2343
+ return { success: { output } };
1788
2344
  case "pi_grep_result":
1789
2345
  case "pi_find_result":
1790
- case "pi_ls_result":
1791
2346
  if (error)
1792
2347
  return { error: { error } };
1793
- return { success: { output } };
2348
+ return { success: { output: groundSearchOutput(output, resultRoot) } };
2349
+ case "pi_ls_result": {
2350
+ if (error)
2351
+ return { error: { error } };
2352
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2353
+ return { success: { output: listing?.text ?? groundSearchOutput(output, resultRoot) } };
2354
+ }
1794
2355
  case "delete_result":
1795
2356
  if (error)
1796
2357
  return { error: { path: "", error } };
@@ -1836,17 +2397,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1836
2397
  case "ls_result": {
1837
2398
  if (error)
1838
2399
  return { error: { path: "", error } };
1839
- const rootPath = resultRoot ?? "";
1840
- const entries = extractPathLines(output);
2400
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2401
+ const rootPath = listing?.directory || resultRoot || "";
2402
+ const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
2403
+ const directories = listing?.directories ?? [];
2404
+ const files = listing?.files ?? entries;
1841
2405
  return {
1842
2406
  success: {
1843
2407
  directory_tree_root: {
1844
2408
  abs_path: rootPath,
1845
- children_dirs: [],
1846
- children_files: entries.map((name) => ({
1847
- name: name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name,
2409
+ children_dirs: directories.map((entry) => ({
2410
+ abs_path: stripTrailingPathSeparator(entry),
2411
+ children_dirs: [],
2412
+ children_files: [],
2413
+ num_files: 0,
1848
2414
  })),
1849
- num_files: entries.length,
2415
+ children_files: files.map((entry) => ({
2416
+ name: listedEntryName(entry),
2417
+ })),
2418
+ num_files: files.length,
1850
2419
  },
1851
2420
  },
1852
2421
  };
@@ -1858,17 +2427,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1858
2427
  // a read call returns through mcp_result. Scope the unwrap to toolName
1859
2428
  // "read" so a non-read MCP tool whose output merely contains a
1860
2429
  // "<content>"-like block is never rewritten.
2430
+ if (toolName === "grep" || toolName === "glob") {
2431
+ return {
2432
+ success: {
2433
+ content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
2434
+ is_error: false,
2435
+ },
2436
+ };
2437
+ }
1861
2438
  if (toolName !== "read") {
1862
2439
  return { success: { content: [{ text: { text: output } }], is_error: false } };
1863
2440
  }
2441
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2442
+ if (listing) {
2443
+ return {
2444
+ success: { content: [{ text: { text: listing.text } }], is_error: false },
2445
+ };
2446
+ }
1864
2447
  // Carry the truncation notice as its own content item: the file content
1865
2448
  // item stays byte-exact, so it can still never be echoed into a write.
1866
- const notice = readTruncationNotice(output);
2449
+ const notices = [
2450
+ readTruncationNotice(output, resultMetadata),
2451
+ readLongLineTruncationNotice(output),
2452
+ ].filter((notice) => !!notice);
1867
2453
  return {
1868
2454
  success: {
1869
2455
  content: [
1870
2456
  { text: { text: unwrapReadOutput(output) } },
1871
- ...(notice ? [{ text: { text: notice } }] : []),
2457
+ ...notices.map((notice) => ({ text: { text: notice } })),
1872
2458
  ],
1873
2459
  is_error: false,
1874
2460
  },
@@ -1945,7 +2531,19 @@ function readEnvelopeFooter(output) {
1945
2531
  return output.slice(start + 2, close);
1946
2532
  }
1947
2533
  /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
1948
- function parseOpenCodeReadMetadata(output) {
2534
+ function parseOpenCodeReadMetadata(output, resultMetadata) {
2535
+ const opencode2 = parseOpenCode2FileRead(output, resultMetadata);
2536
+ if (opencode2) {
2537
+ return {
2538
+ ...(opencode2.startLine !== undefined ? { startLine: opencode2.startLine } : {}),
2539
+ ...(opencode2.endLine !== undefined ? { endLine: opencode2.endLine } : {}),
2540
+ ...(opencode2.totalLines !== undefined ? { totalLines: opencode2.totalLines } : {}),
2541
+ ...(opencode2.nextOffset !== undefined ? { nextOffset: opencode2.nextOffset } : {}),
2542
+ outputCapped: opencode2.outputCapped,
2543
+ hostTruncated: opencode2.hostTruncated,
2544
+ ...(opencode2.truncatedLines.length > 0 ? { truncatedLines: opencode2.truncatedLines } : {}),
2545
+ };
2546
+ }
1949
2547
  const footer = readEnvelopeFooter(output);
1950
2548
  const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
1951
2549
  if (showing) {
@@ -1977,9 +2575,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
1977
2575
  || num(resultMetadata?.limit) !== undefined
1978
2576
  || content.endsWith("\n"))
1979
2577
  return content;
1980
- const absolute = path.isAbsolute(readPath)
2578
+ const absolute = isAbsoluteToolPath(readPath)
1981
2579
  ? readPath
1982
- : path.resolve(workspaceRoot ?? process.cwd(), readPath);
2580
+ : joinToolPath(workspaceRoot ?? process.cwd(), readPath);
1983
2581
  let fd;
1984
2582
  try {
1985
2583
  fd = fs.openSync(absolute, "r");
@@ -2026,8 +2624,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
2026
2624
  * that, keeping the signal out of the file content itself so it still cannot be
2027
2625
  * echoed into a write.
2028
2626
  */
2029
- function readTruncationSummary(output) {
2030
- const meta = parseOpenCodeReadMetadata(output);
2627
+ function readTruncationSummary(output, resultMetadata) {
2628
+ const meta = parseOpenCodeReadMetadata(output, resultMetadata);
2031
2629
  if (meta.startLine === undefined || meta.endLine === undefined)
2032
2630
  return undefined;
2033
2631
  // A complete read reports "(End of file …)" and never reaches this shape.
@@ -2036,7 +2634,7 @@ function readTruncationSummary(output) {
2036
2634
  return {
2037
2635
  startLine: meta.startLine,
2038
2636
  endLine: meta.endLine,
2039
- nextOffset: meta.endLine + 1,
2637
+ nextOffset: meta.nextOffset ?? meta.endLine + 1,
2040
2638
  capped: meta.outputCapped === true,
2041
2639
  ...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
2042
2640
  };
@@ -2049,7 +2647,7 @@ function readTruncationSummary(output) {
2049
2647
  * would cry wolf on every deliberate paged read.
2050
2648
  */
2051
2649
  function readTruncationNotice(output, resultMetadata) {
2052
- const summary = readTruncationSummary(output);
2650
+ const summary = readTruncationSummary(output, resultMetadata);
2053
2651
  if (!summary)
2054
2652
  return undefined;
2055
2653
  const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
@@ -2064,18 +2662,35 @@ function readTruncationNotice(output, resultMetadata) {
2064
2662
  `acting on the whole file; writing the content above back would delete everything ` +
2065
2663
  `after line ${summary.endLine}.]`);
2066
2664
  }
2665
+ function readLongLineTruncationNotice(output) {
2666
+ const lines = parseOpenCodeReadMetadata(output).truncatedLines;
2667
+ if (!lines || lines.length === 0)
2668
+ return undefined;
2669
+ const displayed = lines.slice(0, 8).join(", ");
2670
+ const remainder = lines.length > 8 ? ` and ${lines.length - 8} more` : "";
2671
+ return (`[Partial read: OpenCode shortened ${lines.length === 1 ? "line" : "lines"} ` +
2672
+ `${displayed}${remainder} to ${OPENCODE2_READ_MAX_LINE_CHARS} characters. ` +
2673
+ "It is NOT the complete file. Use a byte-preserving read method to inspect the full " +
2674
+ "line content before acting on the whole file; writing the content above back would lose data.]");
2675
+ }
2067
2676
  /** agent.v1.PiTruncation for a capped OpenCode read. */
2068
- function readTruncationMessage(output, content) {
2069
- const summary = readTruncationSummary(output);
2070
- if (!summary)
2677
+ function readTruncationMessage(output, content, resultMetadata) {
2678
+ const summary = readTruncationSummary(output, resultMetadata);
2679
+ const longLines = parseOpenCodeReadMetadata(output, resultMetadata).truncatedLines;
2680
+ if (!summary && (!longLines || longLines.length === 0))
2681
+ return undefined;
2682
+ const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
2683
+ if (summary && rangeRequested && !summary.capped && (!longLines || longLines.length === 0))
2071
2684
  return undefined;
2072
2685
  return {
2073
2686
  truncated: true,
2074
- truncated_by: summary.capped ? "bytes" : "lines",
2075
- ...(summary.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
2076
- output_lines: Math.max(0, summary.endLine - summary.startLine + 1),
2687
+ truncated_by: summary?.capped ? "bytes" : longLines?.length ? "characters" : "lines",
2688
+ ...(summary?.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
2689
+ output_lines: summary
2690
+ ? Math.max(0, summary.endLine - summary.startLine + 1)
2691
+ : countLines(content),
2077
2692
  output_bytes: Buffer.byteLength(content, "utf8"),
2078
- ...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2693
+ ...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2079
2694
  };
2080
2695
  }
2081
2696
  /** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
@@ -2091,8 +2706,15 @@ function readRangeApplied(resultMetadata, totalLines) {
2091
2706
  return startLine < 0 || startLine <= totalLines;
2092
2707
  }
2093
2708
  function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2709
+ if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
2710
+ return true;
2094
2711
  if (outputMetadata.outputCapped)
2095
2712
  return true;
2713
+ if (outputMetadata.hostTruncated) {
2714
+ const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
2715
+ if (!rangeRequested)
2716
+ return true;
2717
+ }
2096
2718
  const returnedEnd = outputMetadata.endLine;
2097
2719
  if (returnedEnd === undefined || totalLines === 0)
2098
2720
  return false;
@@ -2106,6 +2728,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2106
2728
  : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
2107
2729
  return returnedEnd < expectedEnd;
2108
2730
  }
2731
+ function listedEntryName(entry) {
2732
+ const trimmed = stripTrailingPathSeparator(entry);
2733
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
2734
+ return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
2735
+ }
2736
+ function stripTrailingPathSeparator(entry) {
2737
+ return entry.endsWith("/") || entry.endsWith("\\") ? entry.slice(0, -1) : entry;
2738
+ }
2109
2739
  function readFileSize(filePath) {
2110
2740
  if (!filePath)
2111
2741
  return 0;