cursor-opencode-provider 0.7.0 → 0.7.2

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.
package/README.md CHANGED
@@ -48,7 +48,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
48
48
  - **Model discovery** — fetches available models from Cursor's API and caches them locally
49
49
  - **Image input** — advertises vision only for supported models and forwards OpenCode image attachments to Cursor
50
50
  - **Streaming** — bidirectional Connect-RPC Runs with stale-session rotation, health checks, semantic/read-idle deadlines, bounded replay-safe recovery, and activity-aware held tool continuations
51
- - **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts using only the advertised canonical catalog. Exact advertised subagent names win; standard Cursor roles select compatible OpenCode agents, and native Task maps to OpenCode 1.x `task` or OpenCode 2.0 `subagent`. OCP translates any alternate host vocabulary before it reaches this package. The bridge also covers Cursor's typed read/bash/edit/write/grep/find/ls request/result range, mirrors finalized display-only todo/plan state, and strips OpenCode's `read` XML envelope before returning content to Cursor.
51
+ - **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts using only the advertised canonical catalog. Exact advertised subagent names win; standard Cursor roles select compatible OpenCode agents, and native Task maps to OpenCode 1.x `task` or OpenCode 2.0 `subagent`. OCP translates any alternate host vocabulary before it reaches this package. The bridge also covers Cursor's typed read/bash/edit/write/grep/find/ls request/result range, mirrors finalized display-only todo/plan state, and strips OpenCode 1.x and OpenCode 2 read envelopes before returning content to Cursor.
52
52
  - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
53
53
 
54
54
  ## Requirements
@@ -379,7 +379,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
379
379
  - **Conversation bindings survive provider restarts** — after each successful `TurnEnded`, the OpenCode-session binding, latest checkpoint, checkpoint-reachable KV blob graph, frozen request-context base, last real tool catalog, host agent, stable system-prompt hash, and pending post-compaction rebase marker are atomically replaced in that session's private `<host-cache>/cursor-conversations/*.pb.gz` snapshot. The protobuf stores checkpoints, blob ids/data, and request context as bytes without JSON/base64 expansion, then gzip compresses the complete record. A checkpoint alone is insufficient: Cursor requests its referenced hashes through `get_blob` when the next Run starts. Reachability follows Cursor CLI's export traversal (turns, messages, steps, images, summaries, todos, prompts, and nested subagents), pruning superseded blobs; decode failure or a missing referenced hash conservatively retains the complete blob set. The stable OpenCode session id selects the file; Cursor conversation ids may rotate during compaction, agent switches, or prompt changes and remain inside the snapshot. Independent files prevent concurrent OpenCode processes from losing unrelated sessions by replacing a shared aggregate cache. Startup restores records updated within 24 hours and safely flushes older ones without deleting a concurrently published fresh replacement. Experimental legacy JSON snapshots are discarded rather than migrated. Debug logs report hydration as `restored`, `missing`, `invalid`, or `expired`, plus the compressed and protobuf snapshot sizes. The restored catalog is a fallback only for lifecycle turns such as compaction; an ordinary restricted/no-tool turn never resurrects it. Process-global bindings and prompt/catalog state still use a 256-session LRU bound; memory eviction drops the live checkpoint, blobs, and frozen context, which can be rehydrated from the restart cache on later use.
380
380
  - **Oversized checkpoint graphs rebase instead of flooding the Run stream** — before reusing a checkpoint, the provider measures its reachable KV graph. State above 100 MiB, or an incomplete graph with a missing/undecodable reference, rotates to a fresh Cursor conversation seeded from OpenCode's authoritative history. KV, request-context, MCP-state, interaction, and heartbeat writes are ordered per Run and await HTTP/2 drain, so a large blob reply cannot create an unbounded queue later misreported as heartbeat backpressure. Debug logs distinguish checkpoint-envelope bytes, reachable blob bytes, seed size, and encoded Run-request estimate; raw blob bytes are never presented as tokens.
381
381
  - **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
382
- - **Large reads are capped at 50 KB by OpenCode, and the cap is reported** — OpenCode's `read` tool stops at `MAX_BYTES = 50 * 1024`, cutting on a whole-line boundary, and appends `(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)`. The provider strips that envelope before returning content to Cursor so the model cannot echo wrappers into a later write, but it re-states the cap: a capped native read appends a `[Partial read: …]` marker after the content, a capped MCP read gets a separate notice content item, and a Pi read carries structured `PiReadExecSuccess.truncation`. The structured `truncated` flag alone is not enough — verified against live `gpt-5.4-mini` and `grok-4.5` sessions, both of which asserted "highly confident" that partial content was the whole file until the textual marker was added. Deliberately paged reads (explicit `offset`/`limit`) are not marked. Whole-file writes and overwriting `Add File` patches that echo the marker are rejected before OpenCode executes them; targeted edits remain possible, including edits to source that quotes the warning text. Cursor's private legacy-edit read is handled separately as described above, so an edit can operate on a workspace file larger than 50 KB without turning the capped preview into a replacement. The cap is hardcoded in OpenCode's read tool — it is not the configurable `tool_output.max_bytes`, which governs `Truncate.Service` and not read's line accumulation — so ordinary model reads still require paging with `offset`.
382
+ - **Large reads are capped by OpenCode, and the cap is reported** — OpenCode 1.x `read` stops at `MAX_BYTES = 50 * 1024` and appends `(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)`. OpenCode 2 prints `Read file <path>, lines <start>-<end>` with `N: ` prefixes and, when the page ends before EOF, `[Output truncated. Continue reading with offset: N]` (50 KB or 2,000 lines); it also shortens each individual line after 2,000 characters. The provider strips either envelope before returning content to Cursor so the model cannot echo wrappers into a later write, but it re-states every partial result: a native read appends a `[Partial read: …]` marker after the content, an MCP read gets a separate notice content item, and a Pi read carries structured `PiReadExecSuccess.truncation`. Relative grep, glob, and directory entries are resolved against the workspace root. Shell stdout absolutizes only relative path tokens, not prose. Checkpointed turns, which do not resend the system prompt, get that same root on the user message. The structured `truncated` flag alone is not enough — verified against live `gpt-5.4-mini` and `grok-4.5` sessions, both of which asserted "highly confident" that partial content was the whole file until the textual marker was added. Deliberately paged reads (explicit `offset`/`limit`) are not marked unless the host also hits the byte cap or character-truncates a line. Whole-file writes and overwriting `Add File` patches that echo the marker are rejected before OpenCode executes them; targeted edits remain possible, including edits to source that quotes the warning text. Cursor's private legacy-edit read is handled separately as described above, so an edit can operate on a workspace file larger than 50 KB without turning the capped preview into a replacement. The cap is hardcoded in OpenCode's read tool — it is not the configurable `tool_output.max_bytes`, which governs `Truncate.Service` and not read's line accumulation — so ordinary model reads still require paging with `offset`.
383
383
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
384
384
 
385
385
  ## License
@@ -87,6 +87,15 @@ type ExtractedToolResult = {
87
87
  * are ignored — they are conversation history, not replies for a held-open Run.
88
88
  */
89
89
  export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
90
+ /**
91
+ * Checkpointed Runs do not resend the system prompt. Keep the workspace root
92
+ * on the live user message, and require absolute `path` arguments when that is
93
+ * the host's file-tool dialect.
94
+ */
95
+ export declare function groundCheckpointTurnText(userText: string, checkpoint: boolean, workspaceRoot: string, tools: readonly {
96
+ name?: string;
97
+ inputSchema?: unknown;
98
+ }[]): string;
90
99
  /**
91
100
  * Cursor's native UI interactions cannot be surfaced through the AI SDK.
92
101
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
@@ -9,7 +9,7 @@ import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/mes
9
9
  import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, buildUnsupportedExecDeny, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, preferCorrelatedTaskDescription, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, hostToolDialectFromTools, opencodePathArg, } from "./protocol/tools.js";
10
10
  import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
11
11
  import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
12
- import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
12
+ import { appendCheckpointUserGrounding, appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
13
13
  import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
14
14
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, snapshotMirroredTodos, snapshotMirroredTodosFromReadOutput, } from "./protocol/tool-call-bridge.js";
15
15
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -844,12 +844,15 @@ async function startSession(modelId, token, callOptions, options, startOptions)
844
844
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
845
845
  }
846
846
  const lastUser = [...prompt].reverse().find((message) => message.role === "user");
847
- const userText = recovery?.kind === "rebase"
847
+ let userText = recovery?.kind === "rebase"
848
848
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
849
849
  : (extractUserText(lastUser) || ".");
850
850
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
851
851
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
852
852
  const startedWithCheckpoint = !!conversationState;
853
+ if (startedWithCheckpoint) {
854
+ userText = groundCheckpointTurnText(userText, true, workspaceRoot, cursorTools);
855
+ }
853
856
  const activeMode = getActiveCursorMode(sessionKey);
854
857
  const nativePlanPromptOwnsMode = hostAgent === "plan"
855
858
  && (activeMode === "plan" || activeMode === "spec");
@@ -2934,6 +2937,18 @@ function extractSystemPrompt(prompt) {
2934
2937
  }
2935
2938
  return parts.length > 0 ? parts.join("\n\n") : undefined;
2936
2939
  }
2940
+ /**
2941
+ * Checkpointed Runs do not resend the system prompt. Keep the workspace root
2942
+ * on the live user message, and require absolute `path` arguments when that is
2943
+ * the host's file-tool dialect.
2944
+ */
2945
+ export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, tools) {
2946
+ if (!checkpoint)
2947
+ return userText;
2948
+ return appendCheckpointUserGrounding(userText, workspaceRoot, {
2949
+ requireAbsolutePathArg: hostToolDialectFromTools(tools).filePathKey === "path",
2950
+ });
2951
+ }
2937
2952
  /**
2938
2953
  * Cursor's native UI interactions cannot be surfaced through the AI SDK.
2939
2954
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
@@ -3015,6 +3030,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3015
3030
  // leaving the turn with no file-editing guidance at all.
3016
3031
  instructions.push("- Use OpenCode `apply_patch` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available. Cursor-native write and edit requests are accepted and converted to `apply_patch` automatically.");
3017
3032
  }
3033
+ if (hostToolDialectFromTools(tools).filePathKey === "path") {
3034
+ instructions.push("- OpenCode file tools take `path` as an absolute path under the workspace root above. Do not pass a project-relative path, and do not invent a different absolute prefix.");
3035
+ }
3018
3036
  if (names.has("edit") || names.has("write") || names.has("apply_patch")) {
3019
3037
  instructions.push("- Never use a read result as complete file content when it says the output is capped, partial, or requires another offset. Read the remaining ranges first, or make a targeted edit/patch from complete context; do not pass a partial read back as a whole-file replacement.");
3020
3038
  }
@@ -472,6 +472,8 @@ export function createMessageTypes() {
472
472
  { id: 1, name: "matches", type: "GrepFileMatch", repeated: true },
473
473
  { id: 2, name: "total_lines", type: "int32" },
474
474
  { id: 3, name: "total_matched_lines", type: "int32" },
475
+ { id: 4, name: "client_truncated", type: "bool" },
476
+ { id: 5, name: "ripgrep_truncated", type: "bool" },
475
477
  ]);
476
478
  addType(root, "GrepUnionResult", [
477
479
  { id: 2, name: "files", type: "GrepFilesResult" },
@@ -290,14 +290,13 @@ export declare function buildUnsupportedExecDeny(input: {
290
290
  /**
291
291
  * Strip opencode's `read` envelope, leaving raw file content.
292
292
  *
293
- * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
294
- * envelope its own models are trained on, but Cursor's are not:
293
+ * OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
294
+ * models are trained on, but Cursor's are not:
295
295
  * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
296
- * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
297
- * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
298
- * back into files (silent corruption — the write still reports success; seen
299
- * across 8+ sessions, e.g. language-model.ts rewritten starting with
300
- * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
296
+ * OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
297
+ * the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
298
+ * Forwarding either envelope verbatim made Cursor's model treat the wrapper as
299
+ * literal file content and write tags or line prefixes back into files.
301
300
  *
302
301
  * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
303
302
  *
@@ -860,6 +860,18 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
860
860
  // the requested path for the typed Cursor result.
861
861
  parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
862
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
+ }
863
875
  /**
864
876
  * A partial-read notice is for Cursor's model, never for a file. Refuse only
865
877
  * whole-file mutation forms that echo it. A targeted edit or Update File patch
@@ -888,12 +900,15 @@ export function rejectPartialReadMutation(parsed) {
888
900
  return;
889
901
  const filePath = opencodePathArg(parsed.args) ?? "the target file";
890
902
  const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
903
+ const longLine = content.includes("Use a byte-preserving read method");
891
904
  parsed.localError =
892
905
  `NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
893
906
  "because it contains the provider's partial-read notice. Do not retry the same mutation. " +
894
907
  (nextOffset
895
908
  ? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
896
- : "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.");
897
912
  }
898
913
  export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
899
914
  const id = msg.id;
@@ -986,13 +1001,17 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
986
1001
  // tool name (Cursor's model may have shortened "opencode-read" → "read") and
987
1002
  // decode the argument map back into JSON for opencode to execute.
988
1003
  const m = msg.mcp_args ?? {};
989
- const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), decodeMcpArgs(m.args), "mcp_args", dialect);
1004
+ const rawArgs = decodeMcpArgs(m.args);
1005
+ const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), rawArgs, "mcp_args", dialect);
990
1006
  return {
991
1007
  id,
992
1008
  execId,
993
1009
  toolName: mapped.toolName,
994
1010
  args: mapped.args,
995
1011
  resultField,
1012
+ ...(mapped.toolName === "read"
1013
+ ? { resultMetadata: readRequestResultMetadata(rawArgs, mapped.args) }
1014
+ : {}),
996
1015
  };
997
1016
  }
998
1017
  if (execVariant === "pi_edit_args") {
@@ -1028,13 +1047,11 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
1028
1047
  const rawArgs = msg[execVariant] ?? {};
1029
1048
  const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
1030
1049
  ? shellStreamResultMetadata(rawArgs)
1031
- : execVariant === "read_args"
1032
- ? {
1033
- path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
1034
- ...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
1035
- ...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
1036
- }
1037
- : undefined;
1050
+ : execVariant === "read_args" || execVariant === "pi_read_args"
1051
+ ? readRequestResultMetadata(rawArgs, mapped.args)
1052
+ : execVariant === "grep_args"
1053
+ ? grepRequestResultMetadata(rawArgs)
1054
+ : undefined;
1038
1055
  if (resultMetadata
1039
1056
  && resultMetadata.timeout_behavior !== 2
1040
1057
  && typeof resultMetadata.timeout_ms === "number") {
@@ -1415,9 +1432,11 @@ export function isUriReadTarget(requested) {
1415
1432
  */
1416
1433
  export function resolveReadTargetPath(requested, workspaceRoot) {
1417
1434
  const expanded = untildify(requested);
1418
- if (workspaceRoot && !path.isAbsolute(expanded)) {
1419
- return path.resolve(workspaceRoot, expanded);
1435
+ if (isAbsoluteToolPath(expanded)) {
1436
+ return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
1420
1437
  }
1438
+ if (workspaceRoot)
1439
+ return joinToolPath(workspaceRoot, expanded);
1421
1440
  return path.resolve(expanded);
1422
1441
  }
1423
1442
  /**
@@ -1482,6 +1501,7 @@ export function buildExecClientMessages(input) {
1482
1501
  const resultField = input.resultField || "mcp_result";
1483
1502
  const frames = [];
1484
1503
  if (resultField === "shell_stream") {
1504
+ const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
1485
1505
  // Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
1486
1506
  frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
1487
1507
  if (input.error) {
@@ -1489,8 +1509,8 @@ export function buildExecClientMessages(input) {
1489
1509
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
1490
1510
  }
1491
1511
  else {
1492
- if (input.output) {
1493
- frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
1512
+ if (stdout) {
1513
+ frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
1494
1514
  }
1495
1515
  if (input.shellOutcome?.kind === "backgrounded") {
1496
1516
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
@@ -1700,17 +1720,460 @@ export function buildUnsupportedExecDeny(input) {
1700
1720
  frames.push(buildExecStreamClose(execId));
1701
1721
  return frames;
1702
1722
  }
1723
+ /** OpenCode 2 `read` prints this when a text page does not reach EOF. */
1724
+ const OPENCODE2_READ_TRUNCATION = /^\[Output truncated\. Continue reading with offset:\s*(\d+)\]\s*$/;
1725
+ /** OpenCode 2 stops a read at 2,000 lines as well as 50 KB. */
1726
+ const OPENCODE2_READ_MAX_LINES = 2_000;
1727
+ const OPENCODE2_READ_MAX_LINE_CHARS = 2_000;
1728
+ const OPENCODE2_READ_LINE_TRUNCATION = `... (line truncated to ${OPENCODE2_READ_MAX_LINE_CHARS} chars)`;
1729
+ /**
1730
+ * `read-filesystem.ts` shortens each selected line to 2,000 UTF-16 code units.
1731
+ * One code unit encodes to at most three UTF-8 bytes. Add the 34-byte
1732
+ * `... (line truncated to 2000 chars)` suffix and one line separator when
1733
+ * proving a page was close enough for the next line not to fit.
1734
+ */
1735
+ const OPENCODE2_MAX_RENDERED_LINE_BYTES = (OPENCODE2_READ_MAX_LINE_CHARS * 3) + Buffer.byteLength(OPENCODE2_READ_LINE_TRUNCATION, "utf8") + 1;
1736
+ function normalizeToolText(output) {
1737
+ return output.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1738
+ }
1739
+ /**
1740
+ * OpenCode 2 `ReadTool.toModelContent` for a file. The whole string must match;
1741
+ * a file that merely quotes the header stays a numbered body line.
1742
+ *
1743
+ * Read file <path>, lines <start>-<end>
1744
+ * <n>: <line>
1745
+ * [Output truncated. Continue reading with offset: <next>]
1746
+ */
1747
+ function parseOpenCode2FileRead(output, resultMetadata) {
1748
+ const normalized = normalizeToolText(output).replace(/\n+$/, "");
1749
+ if (!normalized.startsWith("Read file "))
1750
+ return undefined;
1751
+ const lines = normalized.split("\n");
1752
+ const headerLine = (lines[0] ?? "").trim();
1753
+ const empty = /^Read file (.*), 0 lines$/.exec(headerLine);
1754
+ if (empty) {
1755
+ if (lines.length !== 1)
1756
+ return undefined;
1757
+ return {
1758
+ path: empty[1] ?? "",
1759
+ content: "",
1760
+ totalLines: 0,
1761
+ hostTruncated: false,
1762
+ outputCapped: false,
1763
+ truncatedLines: [],
1764
+ };
1765
+ }
1766
+ const header = /^Read file (.*), lines (\d+)-(\d+)$/.exec(headerLine);
1767
+ if (!header)
1768
+ return undefined;
1769
+ const start = Number(header[2]);
1770
+ const end = Number(header[3]);
1771
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
1772
+ return undefined;
1773
+ }
1774
+ let bodyEnd = lines.length;
1775
+ let nextOffset;
1776
+ const banner = OPENCODE2_READ_TRUNCATION.exec(lines[lines.length - 1] ?? "");
1777
+ if (banner) {
1778
+ nextOffset = Number(banner[1]);
1779
+ if (!Number.isSafeInteger(nextOffset) || nextOffset < 1)
1780
+ return undefined;
1781
+ bodyEnd -= 1;
1782
+ }
1783
+ const expected = end - start + 1;
1784
+ if (bodyEnd - 1 !== expected)
1785
+ return undefined;
1786
+ const raw = [];
1787
+ const truncatedLines = [];
1788
+ for (let index = 0; index < expected; index++) {
1789
+ const match = /^(\d+):[ \t](.*)$/.exec(lines[index + 1] ?? "");
1790
+ if (!match || Number(match[1]) !== start + index)
1791
+ return undefined;
1792
+ const value = match[2] ?? "";
1793
+ if (value.length === OPENCODE2_READ_MAX_LINE_CHARS + OPENCODE2_READ_LINE_TRUNCATION.length
1794
+ && value.endsWith(OPENCODE2_READ_LINE_TRUNCATION)) {
1795
+ truncatedLines.push(start + index);
1796
+ }
1797
+ raw.push(value);
1798
+ }
1799
+ const content = raw.join("\n");
1800
+ const hostTruncated = nextOffset !== undefined;
1801
+ const requestedLimit = num(resultMetadata?.limit);
1802
+ const effectiveLineLimit = requestedLimit !== undefined && requestedLimit > 0
1803
+ ? Math.min(requestedLimit, OPENCODE2_READ_MAX_LINES)
1804
+ : OPENCODE2_READ_MAX_LINES;
1805
+ // OpenCode 2 ends a text page only for the requested/default line limit or
1806
+ // the 50 KiB byte limit. Fewer lines than the effective line limit, together
1807
+ // with a page close enough that one maximum-size rendered line may not fit,
1808
+ // identifies the byte cap without the old ASCII-only 4 KiB guess.
1809
+ const outputCapped = hostTruncated
1810
+ && expected < effectiveLineLimit
1811
+ && Buffer.byteLength(content, "utf8") > OPENCODE_READ_MAX_BYTES - OPENCODE2_MAX_RENDERED_LINE_BYTES;
1812
+ if (hostTruncated) {
1813
+ return {
1814
+ path: header[1] ?? "",
1815
+ content,
1816
+ startLine: start,
1817
+ endLine: end,
1818
+ nextOffset,
1819
+ hostTruncated: true,
1820
+ outputCapped,
1821
+ truncatedLines,
1822
+ };
1823
+ }
1824
+ // A page that reached EOF is complete. Record the span so a non-1 offset is
1825
+ // not later mistaken for a silent cap, without flagging it truncated.
1826
+ return {
1827
+ path: header[1] ?? "",
1828
+ content,
1829
+ ...(start === 1
1830
+ ? { totalLines: end }
1831
+ : { startLine: start, endLine: end, totalLines: end }),
1832
+ hostTruncated: false,
1833
+ outputCapped: false,
1834
+ truncatedLines,
1835
+ };
1836
+ }
1837
+ /**
1838
+ * OpenCode 2 directory reads list bare entry names. Join them onto the
1839
+ * directory, then onto the workspace root, so the model can copy a real path
1840
+ * instead of inventing an absolute prefix.
1841
+ */
1842
+ function parseOpenCode2DirectoryListing(output, workspaceRoot) {
1843
+ const normalized = normalizeToolText(output).replace(/\n+$/, "");
1844
+ if (!normalized.startsWith("Read directory "))
1845
+ return undefined;
1846
+ const lines = normalized.split("\n");
1847
+ const header = /^Read directory (.*), (?:0 entries|entries (\d+)-(\d+))$/.exec((lines[0] ?? "").trim());
1848
+ if (!header) {
1849
+ trace("parseOpenCode2DirectoryListing: OpenCode 2 directory header did not match — leaving output unchanged");
1850
+ return undefined;
1851
+ }
1852
+ const requested = header[1] ?? "";
1853
+ if (!isAbsoluteToolPath(requested) && !workspaceRoot)
1854
+ return undefined;
1855
+ let bodyEnd = lines.length;
1856
+ let banner;
1857
+ if (bodyEnd > 1 && OPENCODE2_READ_TRUNCATION.test(lines[bodyEnd - 1] ?? "")) {
1858
+ banner = lines[bodyEnd - 1];
1859
+ bodyEnd -= 1;
1860
+ }
1861
+ const rawEntries = lines.slice(1, bodyEnd);
1862
+ if (header[2] === undefined) {
1863
+ if (rawEntries.length !== 0)
1864
+ return undefined;
1865
+ }
1866
+ else {
1867
+ const start = Number(header[2]);
1868
+ const end = Number(header[3]);
1869
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end - start + 1 !== rawEntries.length) {
1870
+ return undefined;
1871
+ }
1872
+ }
1873
+ const directory = resolveToolPath(requested, workspaceRoot);
1874
+ const directories = [];
1875
+ const files = [];
1876
+ const entries = rawEntries.map((entry) => {
1877
+ const resolved = resolveListedEntry(directory, entry);
1878
+ if (entry.endsWith("/") || entry.endsWith("\\"))
1879
+ directories.push(resolved);
1880
+ else
1881
+ files.push(resolved);
1882
+ return resolved;
1883
+ });
1884
+ const headerLine = header[2] === undefined
1885
+ ? `Read directory ${directory}, 0 entries`
1886
+ : `Read directory ${directory}, entries ${header[2]}-${header[3]}`;
1887
+ return {
1888
+ directory,
1889
+ entries,
1890
+ directories,
1891
+ files,
1892
+ text: [headerLine, ...entries, ...(banner ? [banner] : [])].join("\n"),
1893
+ };
1894
+ }
1895
+ /**
1896
+ * Absolute on this host, plus Windows drive and UNC paths. A posix agent must
1897
+ * not prefix `C:/…` or `\\server\…` just because `path.isAbsolute` rejects them.
1898
+ */
1899
+ function isAbsoluteToolPath(filePath) {
1900
+ return path.isAbsolute(filePath)
1901
+ || /^[A-Za-z]:[\\/]/.test(filePath)
1902
+ || filePath.startsWith("\\\\");
1903
+ }
1904
+ /** Windows drive or UNC path that `path.isAbsolute` rejects on this host. */
1905
+ function isForeignAbsoluteToolPath(filePath) {
1906
+ return isAbsoluteToolPath(filePath) && !path.isAbsolute(filePath);
1907
+ }
1908
+ /** Separator already used by a drive or UNC path. Host paths keep `path.sep`. */
1909
+ function toolPathSeparator(filePath) {
1910
+ if (path.isAbsolute(filePath))
1911
+ return path.sep;
1912
+ if (filePath.startsWith("\\\\") || (filePath.includes("\\") && !filePath.includes("/")))
1913
+ return "\\";
1914
+ return "/";
1915
+ }
1916
+ /**
1917
+ * Join `relative` onto `root`. Node's `path.resolve` treats `C:/…` and
1918
+ * `\\server\…` as relative on posix and prefixes the process cwd. Keep those
1919
+ * roots intact, including `..`, and still use `path.resolve` for host paths.
1920
+ */
1921
+ function joinToolPath(root, relative) {
1922
+ if (!isForeignAbsoluteToolPath(root))
1923
+ return path.resolve(root, relative);
1924
+ const sep = toolPathSeparator(root);
1925
+ const base = splitForeignAbsolute(root, sep);
1926
+ const parts = [...base.segments];
1927
+ for (const part of relative.split(/[\\/]+/)) {
1928
+ if (!part || part === ".")
1929
+ continue;
1930
+ if (part === "..") {
1931
+ if (parts.length > base.frozen)
1932
+ parts.pop();
1933
+ continue;
1934
+ }
1935
+ parts.push(part);
1936
+ }
1937
+ return parts.length === 0 ? base.prefix : `${base.prefix}${parts.join(sep)}`;
1938
+ }
1939
+ function splitForeignAbsolute(filePath, sep) {
1940
+ if (filePath.startsWith("\\\\")) {
1941
+ const segments = filePath.slice(2).split(/[\\/]+/).filter(Boolean);
1942
+ return { prefix: "\\\\", segments, frozen: Math.min(2, segments.length) };
1943
+ }
1944
+ const rest = filePath.slice(2).replace(/^[\\/]+/, "");
1945
+ return {
1946
+ prefix: `${filePath.slice(0, 2)}${sep}`,
1947
+ segments: rest ? rest.split(/[\\/]+/).filter(Boolean) : [],
1948
+ frozen: 0,
1949
+ };
1950
+ }
1951
+ function isRelativePathToken(token) {
1952
+ if (!token || isAbsoluteToolPath(token))
1953
+ return false;
1954
+ // A slash alone does not make arbitrary shell output a path. In particular,
1955
+ // compact JSON, quoted strings, package ids, and shell syntax must remain
1956
+ // byte-for-byte model-visible rather than being prefixed with the workspace.
1957
+ if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
1958
+ return false;
1959
+ if (token.startsWith("./")
1960
+ || token.startsWith("../")
1961
+ || token.startsWith(".\\")
1962
+ || token.startsWith("..\\"))
1963
+ return true;
1964
+ return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
1965
+ }
1966
+ function resolveListedEntry(directory, entry) {
1967
+ if (!entry || isAbsoluteToolPath(entry))
1968
+ return entry;
1969
+ if (entry === "~" || entry.startsWith("~/") || entry.startsWith("~\\"))
1970
+ return entry;
1971
+ const directoryEntry = entry.endsWith("/") || entry.endsWith("\\");
1972
+ const resolved = joinToolPath(directory, entry);
1973
+ if (!directoryEntry || resolved.endsWith("/") || resolved.endsWith("\\"))
1974
+ return resolved;
1975
+ return `${resolved}${toolPathSeparator(resolved)}`;
1976
+ }
1977
+ function resolveToolPath(filePath, workspaceRoot) {
1978
+ if (!filePath || isAbsoluteToolPath(filePath))
1979
+ return filePath;
1980
+ if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\"))
1981
+ return filePath;
1982
+ if (!workspaceRoot)
1983
+ return filePath;
1984
+ return joinToolPath(workspaceRoot, filePath);
1985
+ }
1986
+ /**
1987
+ * Rewrite OpenCode 2 grep/glob lines that are still project-relative. Absolute
1988
+ * paths, indented match previews, and prose stay untouched. Grep file headers
1989
+ * drop the trailing colon only in the structured file list, not in this text.
1990
+ */
1991
+ function groundSearchOutput(output, workspaceRoot) {
1992
+ if (!workspaceRoot)
1993
+ return output;
1994
+ 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");
2002
+ }
2003
+ function isBarePathList(output) {
2004
+ const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
2005
+ if (lines.length === 0 || lines.length > 2000)
2006
+ return false;
2007
+ return lines.every((line) => {
2008
+ if (line.startsWith("("))
2009
+ return true;
2010
+ if (/\s/.test(line) || line.includes("://"))
2011
+ return false;
2012
+ return true;
2013
+ });
2014
+ }
2015
+ function rewriteSearchPathLine(line, workspaceRoot) {
2016
+ if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
2017
+ return line;
2018
+ if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
2019
+ return line;
2020
+ const header = /^(.*):$/.exec(line);
2021
+ if (header && !header[1]?.includes("://")) {
2022
+ return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
2023
+ }
2024
+ if (line.includes("://"))
2025
+ return line;
2026
+ return resolveToolPath(line, workspaceRoot);
2027
+ }
2028
+ /**
2029
+ * Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
2030
+ * paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
2031
+ * status words alone.
2032
+ */
2033
+ function groundShellPathText(output, root) {
2034
+ if (!root || !output)
2035
+ return output;
2036
+ return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
2037
+ }
2038
+ function shellPathRoot(resultMetadata, workspaceRoot) {
2039
+ const workingDirectory = str(resultMetadata?.working_directory)?.trim();
2040
+ if (!workingDirectory)
2041
+ return workspaceRoot;
2042
+ if (isAbsoluteToolPath(workingDirectory))
2043
+ return workingDirectory;
2044
+ return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
2045
+ }
2046
+ function rewriteShellPathLine(line, root) {
2047
+ if (!line || line.startsWith(" ") || line.startsWith("\t"))
2048
+ return line;
2049
+ const trimmed = line.trimEnd();
2050
+ if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
2051
+ return line;
2052
+ }
2053
+ const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
2054
+ if (located && located[1] && isRelativePathToken(located[1])) {
2055
+ const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
2056
+ return `${resolveToolPath(located[1], root)}${suffix}`;
2057
+ }
2058
+ if (/\s/.test(trimmed))
2059
+ return line;
2060
+ const header = /^(.*):$/.exec(trimmed);
2061
+ if (header && header[1] && isRelativePathToken(header[1])) {
2062
+ return `${resolveToolPath(header[1], root)}:`;
2063
+ }
2064
+ if (isRelativePathToken(trimmed))
2065
+ return resolveToolPath(trimmed, root);
2066
+ return line;
2067
+ }
2068
+ const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
2069
+ /**
2070
+ * OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
2071
+ * and indented `Line N: text` previews. A page with no previews is not content.
2072
+ */
2073
+ function parseOpenCodeGrepContent(output, workspaceRoot) {
2074
+ const lines = normalizeToolText(output).split("\n");
2075
+ const first = lines[0]?.trim() ?? "";
2076
+ if (!/^Found \d+ matches\b/.test(first))
2077
+ return undefined;
2078
+ const matches = [];
2079
+ let current;
2080
+ let truncated = /\bmore matches available\b/.test(first);
2081
+ let sawLine = false;
2082
+ for (const raw of lines.slice(1)) {
2083
+ if (!raw.trim())
2084
+ continue;
2085
+ const preview = OPENCODE_GREP_LINE.exec(raw);
2086
+ if (preview) {
2087
+ sawLine = true;
2088
+ if (!current)
2089
+ continue;
2090
+ const lineNumber = Number(preview[1]);
2091
+ if (!Number.isSafeInteger(lineNumber) || lineNumber < 1)
2092
+ continue;
2093
+ current.matches.push({ line_number: lineNumber, content: preview[2] ?? "" });
2094
+ continue;
2095
+ }
2096
+ if (raw.trimStart().startsWith("(")) {
2097
+ if (/truncat/i.test(raw))
2098
+ truncated = true;
2099
+ continue;
2100
+ }
2101
+ if (raw.startsWith(" ") || raw.startsWith("\t"))
2102
+ continue;
2103
+ const header = /^(.*):$/.exec(raw.trim());
2104
+ if (!header || !header[1] || header[1].includes("://"))
2105
+ continue;
2106
+ current = { file: resolveToolPath(header[1], workspaceRoot), matches: [] };
2107
+ matches.push(current);
2108
+ }
2109
+ if (!sawLine)
2110
+ return undefined;
2111
+ const withHits = matches.filter((file) => file.matches.length > 0);
2112
+ if (withHits.length === 0)
2113
+ return undefined;
2114
+ return {
2115
+ matches: withHits,
2116
+ truncated,
2117
+ totalMatchedLines: withHits.reduce((count, file) => count + file.matches.length, 0),
2118
+ };
2119
+ }
2120
+ function grepRequestResultMetadata(raw) {
2121
+ const pattern = str(raw.pattern);
2122
+ const outputMode = str(raw.output_mode);
2123
+ const searchPath = str(raw.path);
2124
+ if (!pattern && !outputMode && !searchPath)
2125
+ return undefined;
2126
+ return {
2127
+ ...(pattern ? { pattern } : {}),
2128
+ ...(outputMode ? { output_mode: outputMode } : {}),
2129
+ ...(searchPath ? { path: searchPath } : {}),
2130
+ };
2131
+ }
2132
+ function extractGroundedPaths(output, workspaceRoot) {
2133
+ const normalized = normalizeToolText(output);
2134
+ const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
2135
+ if (directory)
2136
+ 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") {
2139
+ const paths = [];
2140
+ for (const raw of normalized.split("\n").slice(1)) {
2141
+ const line = raw.trim();
2142
+ if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
2143
+ continue;
2144
+ }
2145
+ const header = /^(.*):$/.exec(line);
2146
+ const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
2147
+ if (!candidate || candidate.includes("://"))
2148
+ continue;
2149
+ paths.push(resolveToolPath(candidate, workspaceRoot));
2150
+ }
2151
+ return paths.slice(0, 2000);
2152
+ }
2153
+ if (isBarePathList(output)) {
2154
+ return output
2155
+ .split("\n")
2156
+ .map((line) => line.trim())
2157
+ .filter((line) => line && !line.startsWith("("))
2158
+ .map((line) => resolveToolPath(line.endsWith(":") && !line.includes("://") ? line.slice(0, -1) : line, workspaceRoot))
2159
+ .slice(0, 2000);
2160
+ }
2161
+ return extractPathLines(output).map((line) => {
2162
+ const trimmed = line.trim();
2163
+ const candidate = trimmed.endsWith(":") && !trimmed.includes("://") ? trimmed.slice(0, -1) : trimmed;
2164
+ return resolveToolPath(candidate, workspaceRoot);
2165
+ });
2166
+ }
1703
2167
  /**
1704
2168
  * Strip opencode's `read` envelope, leaving raw file content.
1705
2169
  *
1706
- * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
1707
- * envelope its own models are trained on, but Cursor's are not:
2170
+ * OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
2171
+ * models are trained on, but Cursor's are not:
1708
2172
  * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
1709
- * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
1710
- * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
1711
- * back into files (silent corruption — the write still reports success; seen
1712
- * across 8+ sessions, e.g. language-model.ts rewritten starting with
1713
- * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
2173
+ * OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
2174
+ * the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
2175
+ * Forwarding either envelope verbatim made Cursor's model treat the wrapper as
2176
+ * literal file content and write tags or line prefixes back into files.
1714
2177
  *
1715
2178
  * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
1716
2179
  *
@@ -1723,6 +2186,12 @@ export function buildUnsupportedExecDeny(input) {
1723
2186
  export function unwrapReadOutput(output) {
1724
2187
  if (typeof output !== "string" || output.length === 0)
1725
2188
  return output;
2189
+ const opencode2 = parseOpenCode2FileRead(output);
2190
+ if (opencode2)
2191
+ return opencode2.content;
2192
+ if (normalizeToolText(output).startsWith("Read file ")) {
2193
+ trace("unwrapReadOutput: OpenCode 2 read header did not match the page parser — leaving output unchanged");
2194
+ }
1726
2195
  // Require the full opencode read-envelope skeleton *before* `<content>`
1727
2196
  // (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
1728
2197
  // stray "<content>" later in tool chatter can't trigger unwrapping just
@@ -1773,34 +2242,53 @@ export function unwrapReadOutput(output) {
1773
2242
  export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
1774
2243
  // Prefer the session workspace; never advertise the host process cwd (daemon
1775
2244
  // often starts in $HOME) as the path Cursor shows the model for glob/ls.
1776
- const resultRoot = typeof workspaceRoot === "string" && workspaceRoot.trim()
1777
- ? path.resolve(workspaceRoot)
2245
+ const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
2246
+ // A posix process must not turn `C:/…` or `\\server\…` into `<cwd>/C:/…`.
2247
+ const resultRoot = trimmedRoot
2248
+ ? (isForeignAbsoluteToolPath(trimmedRoot) ? trimmedRoot : path.resolve(trimmedRoot))
1778
2249
  : undefined;
1779
2250
  switch (resultField) {
1780
2251
  case "read_result": {
1781
- const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
2252
+ const parsedFile = parseOpenCode2FileRead(output, resultMetadata);
2253
+ const listing = error ? undefined : parseOpenCode2DirectoryListing(output, resultRoot);
2254
+ const rawPath = str(resultMetadata?.path)
2255
+ ?? parsedFile?.path
2256
+ ?? listing?.directory
2257
+ ?? extractPathTag(output)
2258
+ ?? "";
2259
+ const readPath = resolveToolPath(rawPath, resultRoot);
1782
2260
  if (error)
1783
2261
  return { error: { path: readPath, error } };
1784
- // Strip opencode's <path>/<content> envelope so Cursor's model receives
1785
- // raw file content and can't echo the wrapper into subsequent writes.
2262
+ // The host may have resolved an alternate spelling after the request
2263
+ // (for example a Unicode-space filename). Use the result's own path for
2264
+ // local stat/newline recovery while preserving the requested path in the
2265
+ // Cursor result, matching the existing OpenCode 1 behavior.
2266
+ const outputPath = parsedFile?.path ?? extractPathTag(output);
2267
+ const statPath = resolveToolPath(outputPath ?? readPath, resultRoot);
2268
+ // Strip OpenCode's read envelope so Cursor's model receives raw file
2269
+ // content and can't echo the wrapper into subsequent writes.
1786
2270
  // Cursor's native read_args lands here; `mcp_args` reads land in
1787
2271
  // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1788
2272
  // native channel, so this is the path that matters in practice.
1789
- const statPath = extractPathTag(output) ?? readPath;
1790
- const outputMetadata = parseOpenCodeReadMetadata(output);
1791
- const content = restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
2273
+ const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
2274
+ const content = listing
2275
+ ? listing.text
2276
+ : restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
1792
2277
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1793
2278
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1794
2279
  // `truncated` alone is not enough: it is set here, and models still assert
1795
2280
  // the partial content is the whole file. Cursor's own executor puts the
1796
2281
  // limit marker in the output text for the same reason, so append one.
1797
- const notice = readTruncationNotice(output, resultMetadata);
2282
+ const notices = [
2283
+ readTruncationNotice(output, resultMetadata),
2284
+ readLongLineTruncationNotice(output),
2285
+ ].filter((notice) => !!notice);
1798
2286
  return {
1799
2287
  success: {
1800
- path: readPath,
1801
- content: notice ? `${content}\n\n${notice}` : content,
2288
+ path: listing?.directory || readPath,
2289
+ content: notices.length > 0 ? `${content}\n\n${notices.join("\n\n")}` : content,
1802
2290
  total_lines: totalLines,
1803
- file_size: readFileSize(statPath),
2291
+ file_size: listing ? 0 : readFileSize(statPath),
1804
2292
  truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
1805
2293
  range_applied: rangeApplied,
1806
2294
  },
@@ -1809,21 +2297,50 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1809
2297
  case "grep_result": {
1810
2298
  if (error)
1811
2299
  return { error: { error } };
1812
- // Prefer files_with_matches: OpenCode glob/grep often returns path lists.
1813
- // Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
1814
- const files = extractPathLines(output);
1815
2300
  const cwd = resultRoot ?? "";
2301
+ const pattern = str(resultMetadata?.pattern) ?? "";
2302
+ const requestedPath = str(resultMetadata?.path) ?? cwd;
2303
+ const requestedMode = str(resultMetadata?.output_mode);
2304
+ // OpenCode always returns match previews. Cursor's native Grep defaults
2305
+ // to content; encoding that as files_with_matches drops the previews and
2306
+ // the model only sees paths. Keep files_with_matches for an explicit
2307
+ // files-only request, and for path lists (glob remaps, no matches).
2308
+ const parsedContent = parseOpenCodeGrepContent(output, resultRoot);
2309
+ const content = requestedMode === "files_with_matches"
2310
+ ? undefined
2311
+ : parsedContent;
2312
+ if (content) {
2313
+ return {
2314
+ success: {
2315
+ pattern,
2316
+ path: requestedPath,
2317
+ output_mode: "content",
2318
+ workspace_results: {
2319
+ [cwd]: {
2320
+ content: {
2321
+ matches: content.matches,
2322
+ total_lines: content.totalMatchedLines,
2323
+ total_matched_lines: content.totalMatchedLines,
2324
+ client_truncated: content.truncated,
2325
+ ripgrep_truncated: false,
2326
+ },
2327
+ },
2328
+ },
2329
+ },
2330
+ };
2331
+ }
2332
+ const files = extractGroundedPaths(output, resultRoot);
1816
2333
  return {
1817
2334
  success: {
1818
- pattern: "",
1819
- path: cwd,
2335
+ pattern,
2336
+ path: requestedPath,
1820
2337
  output_mode: "files_with_matches",
1821
2338
  workspace_results: {
1822
2339
  [cwd]: {
1823
2340
  files: {
1824
2341
  files,
1825
2342
  total_files: files.length,
1826
- client_truncated: false,
2343
+ client_truncated: parsedContent?.truncated ?? false,
1827
2344
  },
1828
2345
  },
1829
2346
  },
@@ -1855,19 +2372,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1855
2372
  // Pi results carry truncation structurally (PiReadExecSuccess field 2),
1856
2373
  // which is how Cursor's own executors report a capped payload.
1857
2374
  const content = unwrapReadOutput(output);
1858
- const truncation = readTruncationMessage(output, content);
2375
+ const truncation = readTruncationMessage(output, content, resultMetadata);
1859
2376
  return { success: { output: content, ...(truncation ? { truncation } : {}) } };
1860
2377
  }
1861
2378
  case "shell_result": {
1862
2379
  const command = str(resultMetadata?.command) ?? "";
1863
2380
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2381
+ const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
1864
2382
  if (error) {
1865
2383
  return {
1866
2384
  failure: {
1867
2385
  command,
1868
2386
  working_directory: workingDirectory,
1869
2387
  exit_code: 1,
1870
- stdout: output || "",
2388
+ stdout: stdout || "",
1871
2389
  stderr: error,
1872
2390
  aborted: false,
1873
2391
  },
@@ -1888,7 +2406,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1888
2406
  command: shellOutcome.command || command,
1889
2407
  working_directory: shellOutcome.workingDirectory || workingDirectory,
1890
2408
  exit_code: 0,
1891
- stdout: output,
2409
+ stdout: stdout,
1892
2410
  shell_id: shellOutcome.shellId,
1893
2411
  pid: shellOutcome.pid,
1894
2412
  ms_to_wait: shellOutcome.msToWait,
@@ -1906,18 +2424,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1906
2424
  command,
1907
2425
  working_directory: workingDirectory,
1908
2426
  exit_code: exitCode,
1909
- stdout: output,
2427
+ stdout: stdout,
1910
2428
  },
1911
2429
  };
1912
2430
  }
1913
2431
  case "pi_bash_result":
2432
+ if (error)
2433
+ return { error: { error } };
2434
+ return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
1914
2435
  case "pi_edit_result":
2436
+ if (error)
2437
+ return { error: { error } };
2438
+ return { success: { output } };
1915
2439
  case "pi_grep_result":
1916
2440
  case "pi_find_result":
1917
- case "pi_ls_result":
1918
2441
  if (error)
1919
2442
  return { error: { error } };
1920
- return { success: { output } };
2443
+ return { success: { output: groundSearchOutput(output, resultRoot) } };
2444
+ case "pi_ls_result": {
2445
+ if (error)
2446
+ return { error: { error } };
2447
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2448
+ return { success: { output: listing?.text ?? groundSearchOutput(output, resultRoot) } };
2449
+ }
1921
2450
  case "delete_result":
1922
2451
  if (error)
1923
2452
  return { error: { path: "", error } };
@@ -1963,17 +2492,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1963
2492
  case "ls_result": {
1964
2493
  if (error)
1965
2494
  return { error: { path: "", error } };
1966
- const rootPath = resultRoot ?? "";
1967
- const entries = extractPathLines(output);
2495
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2496
+ const rootPath = listing?.directory || resultRoot || "";
2497
+ const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
2498
+ const directories = listing?.directories ?? [];
2499
+ const files = listing?.files ?? entries;
1968
2500
  return {
1969
2501
  success: {
1970
2502
  directory_tree_root: {
1971
2503
  abs_path: rootPath,
1972
- children_dirs: [],
1973
- children_files: entries.map((name) => ({
1974
- name: name.includes("/") ? name.slice(name.lastIndexOf("/") + 1) : name,
2504
+ children_dirs: directories.map((entry) => ({
2505
+ abs_path: stripTrailingPathSeparator(entry),
2506
+ children_dirs: [],
2507
+ children_files: [],
2508
+ num_files: 0,
2509
+ })),
2510
+ children_files: files.map((entry) => ({
2511
+ name: listedEntryName(entry),
1975
2512
  })),
1976
- num_files: entries.length,
2513
+ num_files: files.length,
1977
2514
  },
1978
2515
  },
1979
2516
  };
@@ -1985,17 +2522,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1985
2522
  // a read call returns through mcp_result. Scope the unwrap to toolName
1986
2523
  // "read" so a non-read MCP tool whose output merely contains a
1987
2524
  // "<content>"-like block is never rewritten.
2525
+ if (toolName === "grep" || toolName === "glob") {
2526
+ return {
2527
+ success: {
2528
+ content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
2529
+ is_error: false,
2530
+ },
2531
+ };
2532
+ }
1988
2533
  if (toolName !== "read") {
1989
2534
  return { success: { content: [{ text: { text: output } }], is_error: false } };
1990
2535
  }
2536
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2537
+ if (listing) {
2538
+ return {
2539
+ success: { content: [{ text: { text: listing.text } }], is_error: false },
2540
+ };
2541
+ }
1991
2542
  // Carry the truncation notice as its own content item: the file content
1992
2543
  // item stays byte-exact, so it can still never be echoed into a write.
1993
- const notice = readTruncationNotice(output);
2544
+ const notices = [
2545
+ readTruncationNotice(output, resultMetadata),
2546
+ readLongLineTruncationNotice(output),
2547
+ ].filter((notice) => !!notice);
1994
2548
  return {
1995
2549
  success: {
1996
2550
  content: [
1997
2551
  { text: { text: unwrapReadOutput(output) } },
1998
- ...(notice ? [{ text: { text: notice } }] : []),
2552
+ ...notices.map((notice) => ({ text: { text: notice } })),
1999
2553
  ],
2000
2554
  is_error: false,
2001
2555
  },
@@ -2072,7 +2626,19 @@ function readEnvelopeFooter(output) {
2072
2626
  return output.slice(start + 2, close);
2073
2627
  }
2074
2628
  /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
2075
- function parseOpenCodeReadMetadata(output) {
2629
+ function parseOpenCodeReadMetadata(output, resultMetadata) {
2630
+ const opencode2 = parseOpenCode2FileRead(output, resultMetadata);
2631
+ if (opencode2) {
2632
+ return {
2633
+ ...(opencode2.startLine !== undefined ? { startLine: opencode2.startLine } : {}),
2634
+ ...(opencode2.endLine !== undefined ? { endLine: opencode2.endLine } : {}),
2635
+ ...(opencode2.totalLines !== undefined ? { totalLines: opencode2.totalLines } : {}),
2636
+ ...(opencode2.nextOffset !== undefined ? { nextOffset: opencode2.nextOffset } : {}),
2637
+ outputCapped: opencode2.outputCapped,
2638
+ hostTruncated: opencode2.hostTruncated,
2639
+ ...(opencode2.truncatedLines.length > 0 ? { truncatedLines: opencode2.truncatedLines } : {}),
2640
+ };
2641
+ }
2076
2642
  const footer = readEnvelopeFooter(output);
2077
2643
  const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
2078
2644
  if (showing) {
@@ -2104,9 +2670,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
2104
2670
  || num(resultMetadata?.limit) !== undefined
2105
2671
  || content.endsWith("\n"))
2106
2672
  return content;
2107
- const absolute = path.isAbsolute(readPath)
2673
+ const absolute = isAbsoluteToolPath(readPath)
2108
2674
  ? readPath
2109
- : path.resolve(workspaceRoot ?? process.cwd(), readPath);
2675
+ : joinToolPath(workspaceRoot ?? process.cwd(), readPath);
2110
2676
  let fd;
2111
2677
  try {
2112
2678
  fd = fs.openSync(absolute, "r");
@@ -2153,8 +2719,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
2153
2719
  * that, keeping the signal out of the file content itself so it still cannot be
2154
2720
  * echoed into a write.
2155
2721
  */
2156
- function readTruncationSummary(output) {
2157
- const meta = parseOpenCodeReadMetadata(output);
2722
+ function readTruncationSummary(output, resultMetadata) {
2723
+ const meta = parseOpenCodeReadMetadata(output, resultMetadata);
2158
2724
  if (meta.startLine === undefined || meta.endLine === undefined)
2159
2725
  return undefined;
2160
2726
  // A complete read reports "(End of file …)" and never reaches this shape.
@@ -2163,7 +2729,7 @@ function readTruncationSummary(output) {
2163
2729
  return {
2164
2730
  startLine: meta.startLine,
2165
2731
  endLine: meta.endLine,
2166
- nextOffset: meta.endLine + 1,
2732
+ nextOffset: meta.nextOffset ?? meta.endLine + 1,
2167
2733
  capped: meta.outputCapped === true,
2168
2734
  ...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
2169
2735
  };
@@ -2176,7 +2742,7 @@ function readTruncationSummary(output) {
2176
2742
  * would cry wolf on every deliberate paged read.
2177
2743
  */
2178
2744
  function readTruncationNotice(output, resultMetadata) {
2179
- const summary = readTruncationSummary(output);
2745
+ const summary = readTruncationSummary(output, resultMetadata);
2180
2746
  if (!summary)
2181
2747
  return undefined;
2182
2748
  const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
@@ -2191,18 +2757,35 @@ function readTruncationNotice(output, resultMetadata) {
2191
2757
  `acting on the whole file; writing the content above back would delete everything ` +
2192
2758
  `after line ${summary.endLine}.]`);
2193
2759
  }
2760
+ function readLongLineTruncationNotice(output) {
2761
+ const lines = parseOpenCodeReadMetadata(output).truncatedLines;
2762
+ if (!lines || lines.length === 0)
2763
+ return undefined;
2764
+ const displayed = lines.slice(0, 8).join(", ");
2765
+ const remainder = lines.length > 8 ? ` and ${lines.length - 8} more` : "";
2766
+ return (`[Partial read: OpenCode shortened ${lines.length === 1 ? "line" : "lines"} ` +
2767
+ `${displayed}${remainder} to ${OPENCODE2_READ_MAX_LINE_CHARS} characters. ` +
2768
+ "It is NOT the complete file. Use a byte-preserving read method to inspect the full " +
2769
+ "line content before acting on the whole file; writing the content above back would lose data.]");
2770
+ }
2194
2771
  /** agent.v1.PiTruncation for a capped OpenCode read. */
2195
- function readTruncationMessage(output, content) {
2196
- const summary = readTruncationSummary(output);
2197
- if (!summary)
2772
+ function readTruncationMessage(output, content, resultMetadata) {
2773
+ const summary = readTruncationSummary(output, resultMetadata);
2774
+ const longLines = parseOpenCodeReadMetadata(output, resultMetadata).truncatedLines;
2775
+ if (!summary && (!longLines || longLines.length === 0))
2776
+ return undefined;
2777
+ const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
2778
+ if (summary && rangeRequested && !summary.capped && (!longLines || longLines.length === 0))
2198
2779
  return undefined;
2199
2780
  return {
2200
2781
  truncated: true,
2201
- truncated_by: summary.capped ? "bytes" : "lines",
2202
- ...(summary.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
2203
- output_lines: Math.max(0, summary.endLine - summary.startLine + 1),
2782
+ truncated_by: summary?.capped ? "bytes" : longLines?.length ? "characters" : "lines",
2783
+ ...(summary?.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
2784
+ output_lines: summary
2785
+ ? Math.max(0, summary.endLine - summary.startLine + 1)
2786
+ : countLines(content),
2204
2787
  output_bytes: Buffer.byteLength(content, "utf8"),
2205
- ...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2788
+ ...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2206
2789
  };
2207
2790
  }
2208
2791
  /** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
@@ -2218,8 +2801,15 @@ function readRangeApplied(resultMetadata, totalLines) {
2218
2801
  return startLine < 0 || startLine <= totalLines;
2219
2802
  }
2220
2803
  function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2804
+ if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
2805
+ return true;
2221
2806
  if (outputMetadata.outputCapped)
2222
2807
  return true;
2808
+ if (outputMetadata.hostTruncated) {
2809
+ const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
2810
+ if (!rangeRequested)
2811
+ return true;
2812
+ }
2223
2813
  const returnedEnd = outputMetadata.endLine;
2224
2814
  if (returnedEnd === undefined || totalLines === 0)
2225
2815
  return false;
@@ -2233,6 +2823,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2233
2823
  : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
2234
2824
  return returnedEnd < expectedEnd;
2235
2825
  }
2826
+ function listedEntryName(entry) {
2827
+ const trimmed = stripTrailingPathSeparator(entry);
2828
+ const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
2829
+ return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
2830
+ }
2831
+ function stripTrailingPathSeparator(entry) {
2832
+ return entry.endsWith("/") || entry.endsWith("\\") ? entry.slice(0, -1) : entry;
2833
+ }
2236
2834
  function readFileSize(filePath) {
2237
2835
  if (!filePath)
2238
2836
  return 0;
@@ -1 +1,17 @@
1
- export declare function appendWorkspaceRootGrounding(reason: string, workspaceRoot: string | undefined): string;
1
+ export type WorkspaceGroundingOptions = {
2
+ /** OpenCode 2 file tools take `path` and do not say it must be absolute. */
3
+ requireAbsolutePathArg?: boolean;
4
+ };
5
+ /**
6
+ * Attach the exact workspace root to a model-visible string.
7
+ *
8
+ * Idempotent: a second call does not stack another root line. The absolute-path
9
+ * sentence is added only when requested, and only once.
10
+ */
11
+ export declare function appendWorkspaceRootGrounding(reason: string, workspaceRoot: string | undefined, options?: WorkspaceGroundingOptions): string;
12
+ /**
13
+ * Checkpointed Runs omit the system prompt, which is where the root normally
14
+ * lives. Put the same reminder on the live user message so a later turn cannot
15
+ * invent an absolute prefix before any tool result arrives.
16
+ */
17
+ export declare function appendCheckpointUserGrounding(userText: string, workspaceRoot: string | undefined, options?: WorkspaceGroundingOptions): string;
@@ -1,8 +1,37 @@
1
- export function appendWorkspaceRootGrounding(reason, workspaceRoot) {
1
+ const ABSOLUTE_PATH_ARG = "OpenCode file tools take `path` as an absolute path under this root. Do not pass a project-relative path, and do not invent a different absolute prefix.";
2
+ /**
3
+ * Attach the exact workspace root to a model-visible string.
4
+ *
5
+ * Idempotent: a second call does not stack another root line. The absolute-path
6
+ * sentence is added only when requested, and only once.
7
+ */
8
+ export function appendWorkspaceRootGrounding(reason, workspaceRoot, options) {
2
9
  if (!workspaceRoot || !workspaceRoot.trim())
3
10
  return reason;
4
- if (reason.includes("Workspace root:"))
5
- return reason;
6
- return (`${reason}\nWorkspace root: ${JSON.stringify(workspaceRoot)}. ` +
7
- "Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.");
11
+ const pathSentence = options?.requireAbsolutePathArg ? ` ${ABSOLUTE_PATH_ARG}` : "";
12
+ if (reason.includes("Workspace root:")) {
13
+ if (!pathSentence || reason.includes("take `path` as an absolute path"))
14
+ return reason;
15
+ return `${reason}${pathSentence}`;
16
+ }
17
+ const note = `Workspace root: ${JSON.stringify(workspaceRoot)}. ` +
18
+ "Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them." +
19
+ pathSentence;
20
+ if (!reason)
21
+ return note;
22
+ return `${reason}\n${note}`;
23
+ }
24
+ /**
25
+ * Checkpointed Runs omit the system prompt, which is where the root normally
26
+ * lives. Put the same reminder on the live user message so a later turn cannot
27
+ * invent an absolute prefix before any tool result arrives.
28
+ */
29
+ export function appendCheckpointUserGrounding(userText, workspaceRoot, options) {
30
+ const note = appendWorkspaceRootGrounding("", workspaceRoot, options);
31
+ if (!note)
32
+ return userText;
33
+ if (userText.includes("Workspace root:")) {
34
+ return appendWorkspaceRootGrounding(userText, workspaceRoot, options);
35
+ }
36
+ return userText ? `${userText}\n\n${note}` : note;
8
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",