cursor-opencode-provider 0.7.0 → 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.
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
  }
@@ -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,12 +1047,8 @@ 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
- }
1050
+ : execVariant === "read_args" || execVariant === "pi_read_args"
1051
+ ? readRequestResultMetadata(rawArgs, mapped.args)
1037
1052
  : undefined;
1038
1053
  if (resultMetadata
1039
1054
  && resultMetadata.timeout_behavior !== 2
@@ -1415,9 +1430,11 @@ export function isUriReadTarget(requested) {
1415
1430
  */
1416
1431
  export function resolveReadTargetPath(requested, workspaceRoot) {
1417
1432
  const expanded = untildify(requested);
1418
- if (workspaceRoot && !path.isAbsolute(expanded)) {
1419
- return path.resolve(workspaceRoot, expanded);
1433
+ if (isAbsoluteToolPath(expanded)) {
1434
+ return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
1420
1435
  }
1436
+ if (workspaceRoot)
1437
+ return joinToolPath(workspaceRoot, expanded);
1421
1438
  return path.resolve(expanded);
1422
1439
  }
1423
1440
  /**
@@ -1482,6 +1499,7 @@ export function buildExecClientMessages(input) {
1482
1499
  const resultField = input.resultField || "mcp_result";
1483
1500
  const frames = [];
1484
1501
  if (resultField === "shell_stream") {
1502
+ const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
1485
1503
  // Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
1486
1504
  frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
1487
1505
  if (input.error) {
@@ -1489,8 +1507,8 @@ export function buildExecClientMessages(input) {
1489
1507
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
1490
1508
  }
1491
1509
  else {
1492
- if (input.output) {
1493
- frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
1510
+ if (stdout) {
1511
+ frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
1494
1512
  }
1495
1513
  if (input.shellOutcome?.kind === "backgrounded") {
1496
1514
  frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
@@ -1700,17 +1718,396 @@ export function buildUnsupportedExecDeny(input) {
1700
1718
  frames.push(buildExecStreamClose(execId));
1701
1719
  return frames;
1702
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
+ }
1703
2101
  /**
1704
2102
  * Strip opencode's `read` envelope, leaving raw file content.
1705
2103
  *
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:
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:
1708
2106
  * <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…`).
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.
1714
2111
  *
1715
2112
  * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
1716
2113
  *
@@ -1723,6 +2120,12 @@ export function buildUnsupportedExecDeny(input) {
1723
2120
  export function unwrapReadOutput(output) {
1724
2121
  if (typeof output !== "string" || output.length === 0)
1725
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
+ }
1726
2129
  // Require the full opencode read-envelope skeleton *before* `<content>`
1727
2130
  // (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
1728
2131
  // stray "<content>" later in tool chatter can't trigger unwrapping just
@@ -1773,34 +2176,53 @@ export function unwrapReadOutput(output) {
1773
2176
  export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
1774
2177
  // Prefer the session workspace; never advertise the host process cwd (daemon
1775
2178
  // 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)
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))
1778
2183
  : undefined;
1779
2184
  switch (resultField) {
1780
2185
  case "read_result": {
1781
- 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);
1782
2194
  if (error)
1783
2195
  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.
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.
1786
2204
  // Cursor's native read_args lands here; `mcp_args` reads land in
1787
2205
  // mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
1788
2206
  // 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);
2207
+ const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
2208
+ const content = listing
2209
+ ? listing.text
2210
+ : restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
1792
2211
  const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
1793
2212
  const rangeApplied = readRangeApplied(resultMetadata, totalLines);
1794
2213
  // `truncated` alone is not enough: it is set here, and models still assert
1795
2214
  // the partial content is the whole file. Cursor's own executor puts the
1796
2215
  // limit marker in the output text for the same reason, so append one.
1797
- const notice = readTruncationNotice(output, resultMetadata);
2216
+ const notices = [
2217
+ readTruncationNotice(output, resultMetadata),
2218
+ readLongLineTruncationNotice(output),
2219
+ ].filter((notice) => !!notice);
1798
2220
  return {
1799
2221
  success: {
1800
- path: readPath,
1801
- 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,
1802
2224
  total_lines: totalLines,
1803
- file_size: readFileSize(statPath),
2225
+ file_size: listing ? 0 : readFileSize(statPath),
1804
2226
  truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
1805
2227
  range_applied: rangeApplied,
1806
2228
  },
@@ -1811,7 +2233,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1811
2233
  return { error: { error } };
1812
2234
  // Prefer files_with_matches: OpenCode glob/grep often returns path lists.
1813
2235
  // Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
1814
- const files = extractPathLines(output);
2236
+ const files = extractGroundedPaths(output, resultRoot);
1815
2237
  const cwd = resultRoot ?? "";
1816
2238
  return {
1817
2239
  success: {
@@ -1855,19 +2277,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1855
2277
  // Pi results carry truncation structurally (PiReadExecSuccess field 2),
1856
2278
  // which is how Cursor's own executors report a capped payload.
1857
2279
  const content = unwrapReadOutput(output);
1858
- const truncation = readTruncationMessage(output, content);
2280
+ const truncation = readTruncationMessage(output, content, resultMetadata);
1859
2281
  return { success: { output: content, ...(truncation ? { truncation } : {}) } };
1860
2282
  }
1861
2283
  case "shell_result": {
1862
2284
  const command = str(resultMetadata?.command) ?? "";
1863
2285
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
2286
+ const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
1864
2287
  if (error) {
1865
2288
  return {
1866
2289
  failure: {
1867
2290
  command,
1868
2291
  working_directory: workingDirectory,
1869
2292
  exit_code: 1,
1870
- stdout: output || "",
2293
+ stdout: stdout || "",
1871
2294
  stderr: error,
1872
2295
  aborted: false,
1873
2296
  },
@@ -1888,7 +2311,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1888
2311
  command: shellOutcome.command || command,
1889
2312
  working_directory: shellOutcome.workingDirectory || workingDirectory,
1890
2313
  exit_code: 0,
1891
- stdout: output,
2314
+ stdout: stdout,
1892
2315
  shell_id: shellOutcome.shellId,
1893
2316
  pid: shellOutcome.pid,
1894
2317
  ms_to_wait: shellOutcome.msToWait,
@@ -1906,18 +2329,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1906
2329
  command,
1907
2330
  working_directory: workingDirectory,
1908
2331
  exit_code: exitCode,
1909
- stdout: output,
2332
+ stdout: stdout,
1910
2333
  },
1911
2334
  };
1912
2335
  }
1913
2336
  case "pi_bash_result":
2337
+ if (error)
2338
+ return { error: { error } };
2339
+ return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
1914
2340
  case "pi_edit_result":
2341
+ if (error)
2342
+ return { error: { error } };
2343
+ return { success: { output } };
1915
2344
  case "pi_grep_result":
1916
2345
  case "pi_find_result":
1917
- case "pi_ls_result":
1918
2346
  if (error)
1919
2347
  return { error: { error } };
1920
- 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
+ }
1921
2355
  case "delete_result":
1922
2356
  if (error)
1923
2357
  return { error: { path: "", error } };
@@ -1963,17 +2397,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1963
2397
  case "ls_result": {
1964
2398
  if (error)
1965
2399
  return { error: { path: "", error } };
1966
- const rootPath = resultRoot ?? "";
1967
- 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;
1968
2405
  return {
1969
2406
  success: {
1970
2407
  directory_tree_root: {
1971
2408
  abs_path: rootPath,
1972
- children_dirs: [],
1973
- children_files: entries.map((name) => ({
1974
- 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,
1975
2414
  })),
1976
- num_files: entries.length,
2415
+ children_files: files.map((entry) => ({
2416
+ name: listedEntryName(entry),
2417
+ })),
2418
+ num_files: files.length,
1977
2419
  },
1978
2420
  },
1979
2421
  };
@@ -1985,17 +2427,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1985
2427
  // a read call returns through mcp_result. Scope the unwrap to toolName
1986
2428
  // "read" so a non-read MCP tool whose output merely contains a
1987
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
+ }
1988
2438
  if (toolName !== "read") {
1989
2439
  return { success: { content: [{ text: { text: output } }], is_error: false } };
1990
2440
  }
2441
+ const listing = parseOpenCode2DirectoryListing(output, resultRoot);
2442
+ if (listing) {
2443
+ return {
2444
+ success: { content: [{ text: { text: listing.text } }], is_error: false },
2445
+ };
2446
+ }
1991
2447
  // Carry the truncation notice as its own content item: the file content
1992
2448
  // item stays byte-exact, so it can still never be echoed into a write.
1993
- const notice = readTruncationNotice(output);
2449
+ const notices = [
2450
+ readTruncationNotice(output, resultMetadata),
2451
+ readLongLineTruncationNotice(output),
2452
+ ].filter((notice) => !!notice);
1994
2453
  return {
1995
2454
  success: {
1996
2455
  content: [
1997
2456
  { text: { text: unwrapReadOutput(output) } },
1998
- ...(notice ? [{ text: { text: notice } }] : []),
2457
+ ...notices.map((notice) => ({ text: { text: notice } })),
1999
2458
  ],
2000
2459
  is_error: false,
2001
2460
  },
@@ -2072,7 +2531,19 @@ function readEnvelopeFooter(output) {
2072
2531
  return output.slice(start + 2, close);
2073
2532
  }
2074
2533
  /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
2075
- 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
+ }
2076
2547
  const footer = readEnvelopeFooter(output);
2077
2548
  const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
2078
2549
  if (showing) {
@@ -2104,9 +2575,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
2104
2575
  || num(resultMetadata?.limit) !== undefined
2105
2576
  || content.endsWith("\n"))
2106
2577
  return content;
2107
- const absolute = path.isAbsolute(readPath)
2578
+ const absolute = isAbsoluteToolPath(readPath)
2108
2579
  ? readPath
2109
- : path.resolve(workspaceRoot ?? process.cwd(), readPath);
2580
+ : joinToolPath(workspaceRoot ?? process.cwd(), readPath);
2110
2581
  let fd;
2111
2582
  try {
2112
2583
  fd = fs.openSync(absolute, "r");
@@ -2153,8 +2624,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
2153
2624
  * that, keeping the signal out of the file content itself so it still cannot be
2154
2625
  * echoed into a write.
2155
2626
  */
2156
- function readTruncationSummary(output) {
2157
- const meta = parseOpenCodeReadMetadata(output);
2627
+ function readTruncationSummary(output, resultMetadata) {
2628
+ const meta = parseOpenCodeReadMetadata(output, resultMetadata);
2158
2629
  if (meta.startLine === undefined || meta.endLine === undefined)
2159
2630
  return undefined;
2160
2631
  // A complete read reports "(End of file …)" and never reaches this shape.
@@ -2163,7 +2634,7 @@ function readTruncationSummary(output) {
2163
2634
  return {
2164
2635
  startLine: meta.startLine,
2165
2636
  endLine: meta.endLine,
2166
- nextOffset: meta.endLine + 1,
2637
+ nextOffset: meta.nextOffset ?? meta.endLine + 1,
2167
2638
  capped: meta.outputCapped === true,
2168
2639
  ...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
2169
2640
  };
@@ -2176,7 +2647,7 @@ function readTruncationSummary(output) {
2176
2647
  * would cry wolf on every deliberate paged read.
2177
2648
  */
2178
2649
  function readTruncationNotice(output, resultMetadata) {
2179
- const summary = readTruncationSummary(output);
2650
+ const summary = readTruncationSummary(output, resultMetadata);
2180
2651
  if (!summary)
2181
2652
  return undefined;
2182
2653
  const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
@@ -2191,18 +2662,35 @@ function readTruncationNotice(output, resultMetadata) {
2191
2662
  `acting on the whole file; writing the content above back would delete everything ` +
2192
2663
  `after line ${summary.endLine}.]`);
2193
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
+ }
2194
2676
  /** agent.v1.PiTruncation for a capped OpenCode read. */
2195
- function readTruncationMessage(output, content) {
2196
- const summary = readTruncationSummary(output);
2197
- 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))
2198
2684
  return undefined;
2199
2685
  return {
2200
2686
  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),
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),
2204
2692
  output_bytes: Buffer.byteLength(content, "utf8"),
2205
- ...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2693
+ ...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
2206
2694
  };
2207
2695
  }
2208
2696
  /** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
@@ -2218,8 +2706,15 @@ function readRangeApplied(resultMetadata, totalLines) {
2218
2706
  return startLine < 0 || startLine <= totalLines;
2219
2707
  }
2220
2708
  function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2709
+ if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
2710
+ return true;
2221
2711
  if (outputMetadata.outputCapped)
2222
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
+ }
2223
2718
  const returnedEnd = outputMetadata.endLine;
2224
2719
  if (returnedEnd === undefined || totalLines === 0)
2225
2720
  return false;
@@ -2233,6 +2728,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
2233
2728
  : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
2234
2729
  return returnedEnd < expectedEnd;
2235
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
+ }
2236
2739
  function readFileSize(filePath) {
2237
2740
  if (!filePath)
2238
2741
  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.1",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",