cursor-opencode-provider 0.3.0 → 0.3.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 +12 -3
- package/dist/agent-url.js +2 -0
- package/dist/context/paths.js +3 -2
- package/dist/language-model.js +14 -2
- package/dist/model-config.js +20 -2
- package/dist/protocol/apply-patch.d.ts +109 -0
- package/dist/protocol/apply-patch.js +163 -0
- package/dist/protocol/messages.js +27 -1
- package/dist/protocol/tools.d.ts +16 -0
- package/dist/protocol/tools.js +253 -15
- package/dist/shell-timeout.js +6 -2
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Add the package to OpenCode config. OpenCode installs npm plugins with Bun at st
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Pin a version if you want: `"cursor-opencode-provider@0.
|
|
50
|
+
Pin a version if you want: `"cursor-opencode-provider@0.3.1"`.
|
|
51
51
|
|
|
52
52
|
You can also install it yourself first:
|
|
53
53
|
|
|
@@ -117,7 +117,7 @@ browser login or an API key. `CURSOR_API_KEY` is also picked up automatically.
|
|
|
117
117
|
}
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
Pin a version if you want: `"cursor-opencode-provider@0.
|
|
120
|
+
Pin a version if you want: `"cursor-opencode-provider@0.3.1/plugin/opencode2"`.
|
|
121
121
|
|
|
122
122
|
OpenCode 2.0 installs the published package into its host cache and loads the AI SDK
|
|
123
123
|
entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required.
|
|
@@ -350,10 +350,18 @@ OpenCode
|
|
|
350
350
|
|
|
351
351
|
### Injected system guidance
|
|
352
352
|
|
|
353
|
-
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
|
|
353
|
+
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
|
|
354
354
|
|
|
355
355
|
If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
|
|
356
356
|
|
|
357
|
+
### `apply_patch` models (GPT-5 and friends)
|
|
358
|
+
|
|
359
|
+
OpenCode 1.x does not simply add `apply_patch` for GPT-series models — it **removes** `edit` and `write` from the tool catalog and advertises `apply_patch` in their place (`ToolRegistry.tools`; the model id must contain `gpt-` and neither `oss` nor `gpt-4`). Cursor keeps sending its native write/edit requests regardless, so without translation every file change on a `gpt-5*` model is refused as an unavailable tool.
|
|
360
|
+
|
|
361
|
+
The provider translates transparently: a native write becomes an `*** Add File:` patch, and a Pi edit becomes a minimal `*** Update File:` chunk widened to whole lines. This is keyed purely off what the host advertises — it is inert whenever `edit`/`write` are offered normally, and inert again when the host offers neither those nor `apply_patch`. An edit that cannot be expressed faithfully (target unreadable, or the text to replace is absent or ambiguous) is refused with a specific message rather than applied to the wrong region.
|
|
362
|
+
|
|
363
|
+
OpenCode 2.0 does not perform this substitution, so the path is 1.x-only in practice. See [`src/protocol/apply-patch.ts`](src/protocol/apply-patch.ts) for the upstream references.
|
|
364
|
+
|
|
357
365
|
### Native subagent routing
|
|
358
366
|
|
|
359
367
|
The provider reads the permission-filtered subagent catalog from the current host `task` or `actor` definition and advertises those recipients to Cursor as custom subagents. Exact configured names are preserved; unknown future Cursor subtype strings fall back to `general`. If a complete catalog omits every compatible recipient, the request fails on Cursor's typed subagent channel instead of selecting an arbitrary specialist.
|
|
@@ -412,6 +420,7 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
412
420
|
- **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state.
|
|
413
421
|
- **Conversation bindings and compaction catalogs are bounded** — process-global per-session bindings, prior tool catalogs, and pending post-compaction rebases use a 256-session LRU bound. Evicting a conversation binding also drops its checkpoint and KV blobs.
|
|
414
422
|
- **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.
|
|
423
|
+
- **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. Without that signal the model treats a capped read as the whole file and rewrites it truncated, discarding everything past the cap. 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 the only lever is paging with `offset`. Prefer `edit` over whole-file `write` on files above 50 KB.
|
|
415
424
|
- **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
|
|
416
425
|
|
|
417
426
|
## License
|
package/dist/agent-url.js
CHANGED
|
@@ -21,6 +21,8 @@ function normalizeApiBaseURL(baseURL) {
|
|
|
21
21
|
return new URL(baseURL).origin;
|
|
22
22
|
}
|
|
23
23
|
function resolveCacheKey(token, options) {
|
|
24
|
+
// codeql[js/insufficient-password-hash] -- The token is a high-entropy JWT/API key,
|
|
25
|
+
// and this digest is only an ephemeral memoization key, never a password verifier.
|
|
24
26
|
const tokenHash = createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
25
27
|
return `${tokenHash}|${normalizeApiBaseURL(options.apiBaseURL ?? options.baseURL)}|telem:${options.telemetryEnabled === true}`;
|
|
26
28
|
}
|
package/dist/context/paths.js
CHANGED
|
@@ -145,8 +145,9 @@ export function slugifyWorkspacePath(workspaceRoot) {
|
|
|
145
145
|
const resolved = path.resolve(workspaceRoot);
|
|
146
146
|
return resolved
|
|
147
147
|
.replace(/[^a-zA-Z0-9]/g, "-")
|
|
148
|
-
.
|
|
149
|
-
.
|
|
148
|
+
.split("-")
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.join("-");
|
|
150
151
|
}
|
|
151
152
|
/**
|
|
152
153
|
* Cursor-style project metadata root for a workspace.
|
package/dist/language-model.js
CHANGED
|
@@ -6,7 +6,7 @@ import { trace, traceRequestContextPaths } from "./debug.js";
|
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
8
|
import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
|
|
9
|
-
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
10
10
|
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
11
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
12
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
@@ -954,7 +954,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
954
954
|
if (!displayCallId ||
|
|
955
955
|
parsed.resultField !== "read_result" ||
|
|
956
956
|
parsed.toolName !== "read" ||
|
|
957
|
-
|
|
957
|
+
// `apply_patch` is the substitute this host offers when it withholds
|
|
958
|
+
// `write` — the follow-up write_args is remapped onto it either way.
|
|
959
|
+
!(advertisedToolNameSet.has("write") || advertisedToolNameSet.has("apply_patch")))
|
|
958
960
|
return false;
|
|
959
961
|
const stored = session.displayToolCalls.get(displayCallId);
|
|
960
962
|
const display = parseDisplayToolCall(displayCallId, stored);
|
|
@@ -1377,6 +1379,10 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1377
1379
|
parsed.toolName = executableToolName;
|
|
1378
1380
|
}
|
|
1379
1381
|
remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
|
|
1382
|
+
// Hosts that advertise `apply_patch` in place of `edit`/`write` (see
|
|
1383
|
+
// protocol/apply-patch.ts) still receive Cursor's native write/edit
|
|
1384
|
+
// exec requests. Translate before the unavailable-tool check below.
|
|
1385
|
+
remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
|
|
1380
1386
|
}
|
|
1381
1387
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
1382
1388
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
@@ -1663,6 +1669,12 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1663
1669
|
? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
|
|
1664
1670
|
: "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
|
|
1665
1671
|
}
|
|
1672
|
+
else if (names.has("apply_patch")) {
|
|
1673
|
+
// This host withheld `edit`/`write` and offers `apply_patch` instead. Native
|
|
1674
|
+
// Cursor write/edit requests are translated into it, so say so rather than
|
|
1675
|
+
// leaving the turn with no file-editing guidance at all.
|
|
1676
|
+
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.");
|
|
1677
|
+
}
|
|
1666
1678
|
return [
|
|
1667
1679
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1668
1680
|
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
package/dist/model-config.js
CHANGED
|
@@ -19,9 +19,27 @@ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, parseCursorCon
|
|
|
19
19
|
* Dots and unicode letters are preserved so names stay readable.
|
|
20
20
|
* Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
21
21
|
*/
|
|
22
|
+
function stripMarkupTags(value) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let cursor = 0;
|
|
25
|
+
while (cursor < value.length) {
|
|
26
|
+
const start = value.indexOf("<", cursor);
|
|
27
|
+
if (start === -1) {
|
|
28
|
+
chunks.push(value.slice(cursor));
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
chunks.push(value.slice(cursor, start));
|
|
32
|
+
const end = value.indexOf(">", start + 1);
|
|
33
|
+
if (end === -1) {
|
|
34
|
+
chunks.push(value.slice(start));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
cursor = end + 1;
|
|
38
|
+
}
|
|
39
|
+
return chunks.join("");
|
|
40
|
+
}
|
|
22
41
|
function safeLabel(value) {
|
|
23
|
-
return (value
|
|
24
|
-
.replace(/<[^>]+>/g, "")
|
|
42
|
+
return (stripMarkupTags(value)
|
|
25
43
|
.replace(/[()<>&"'`]/g, "")
|
|
26
44
|
.replace(/\s+/g, " ")
|
|
27
45
|
.trim() || "default");
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesis of OpenCode `apply_patch` envelopes from Cursor's native write/edit
|
|
3
|
+
* exec requests.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* OpenCode 1.x does not merely *add* `apply_patch` for GPT models — it removes
|
|
8
|
+
* `edit` and `write` from the catalog in exchange. From
|
|
9
|
+
* `packages/opencode/src/tool/registry.ts` (`ToolRegistry.tools`):
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // use apply tool in same format as codex
|
|
13
|
+
* const usePatch =
|
|
14
|
+
* input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
|
|
15
|
+
* if (tool.id === ApplyPatchTool.id) return usePatch
|
|
16
|
+
* if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The predicate runs on the *API* model id, so every Cursor `gpt-5*` model
|
|
20
|
+
* matches (and, as a quirk of substring matching, `gpt-3.5-*` does too while
|
|
21
|
+
* `gpt-4o` does not).
|
|
22
|
+
*
|
|
23
|
+
* Upstream context, in case this ever needs revisiting:
|
|
24
|
+
*
|
|
25
|
+
* - Introduced by opencode PR #9127 ("apply_patch tool for openai models"). The
|
|
26
|
+
* intent is to hand GPT/Codex models the tool surface they were post-trained
|
|
27
|
+
* on; the same commit tells the codex system prompt to prefer `apply_patch`.
|
|
28
|
+
* - The *exclusivity* is deliberate. On opencode issue #34491 a maintainer
|
|
29
|
+
* describes the gate as a "band-aid", "intentionally minimal", whose purpose
|
|
30
|
+
* is that "GPT no longer receives edit + write + apply_patch simultaneously",
|
|
31
|
+
* and notes that a plugin-side opt-out "isn't feasible today".
|
|
32
|
+
* - opencode issue #19942 reported exactly this hazard — model-conditional tool
|
|
33
|
+
* substitution makes model-agnostic integrations impossible — and was closed
|
|
34
|
+
* `not_planned` by a stale bot.
|
|
35
|
+
* - opencode issue #35408 (open, milestone 2.0) proposes a
|
|
36
|
+
* `ctx.session.context(...)` hook exposing a mutable tool roster, and lists
|
|
37
|
+
* "Select `apply_patch` versus `edit` + `write`" as work to move out of the
|
|
38
|
+
* registry. Once that lands, a host-side opt-out replaces this shim.
|
|
39
|
+
* - OpenCode 2.0 (`packages/core`) has **no** such gate: `apply_patch`, `edit`
|
|
40
|
+
* and `write` are all registered unconditionally in `tool/builtins.ts`. This
|
|
41
|
+
* translation is therefore 1.x-only in practice, but it is keyed off the
|
|
42
|
+
* advertised set rather than a version check, so it simply never fires on 2.0.
|
|
43
|
+
*
|
|
44
|
+
* ## Why translation, not advertisement
|
|
45
|
+
*
|
|
46
|
+
* `toolsToDescriptors` forwards every advertised host tool to Cursor verbatim,
|
|
47
|
+
* so `opencode-apply_patch` already appears in Cursor's catalog whenever
|
|
48
|
+
* OpenCode advertises it, and an `mcp_args` call naming it already executes.
|
|
49
|
+
* What breaks is Cursor's *native* write/edit channel (`write_args`,
|
|
50
|
+
* `pi_edit_args`), which this provider resolves to the fixed host names
|
|
51
|
+
* `write`/`edit`.
|
|
52
|
+
*
|
|
53
|
+
* Translating on our side is the only option: Cursor's agent protocol has no
|
|
54
|
+
* patch-format edit tool at all (its `ToolCall` oneof offers whole-file
|
|
55
|
+
* `edit_tool_call`/`write_args` and substring `pi_edit_tool_call`/`pi_edit_args`;
|
|
56
|
+
* `apply_agent_diff_tool_call` is unrelated — it applies a cloud agent's diff by
|
|
57
|
+
* `agent_id`). Cursor's CLI also contains no model-conditional branching
|
|
58
|
+
* whatsoever, and `ModelDetails` carries no tool-capability flags, so the same
|
|
59
|
+
* native requests arrive for every model. Those names are absent from
|
|
60
|
+
* the catalog, so the request is refused before it reaches OpenCode.
|
|
61
|
+
*
|
|
62
|
+
* ## Format notes (verified against `packages/opencode/src/patch/index.ts`)
|
|
63
|
+
*
|
|
64
|
+
* - `*** Add File:` **overwrites an existing file** — the tool's `add` case uses
|
|
65
|
+
* `oldContent = ""` and never stats the target (upstream test: "adds file
|
|
66
|
+
* overwriting existing file"). A whole-file write therefore needs no diff and
|
|
67
|
+
* no existence check.
|
|
68
|
+
* - An empty `@@` header is a valid chunk start: the parser stores
|
|
69
|
+
* `change_context: contextLine || undefined`, and a missing context simply
|
|
70
|
+
* means "match `old_lines` from the current scan position".
|
|
71
|
+
* - Every payload line is prefixed with `+`, `-`, or a space, and the parser
|
|
72
|
+
* only breaks on *unprefixed* `***` / `@@`. Content lines that themselves
|
|
73
|
+
* begin with `***` or `@@` therefore survive a round trip unharmed.
|
|
74
|
+
* - `Add File` content is rejoined with `\n` and normalized to exactly one
|
|
75
|
+
* trailing newline. A write of content with no trailing newline gains one.
|
|
76
|
+
* - Update chunks match **whole lines** via `seekSequence`, unlike OpenCode
|
|
77
|
+
* `edit`, which is substring-based. Substring edits must be expanded to line
|
|
78
|
+
* boundaries before they can be expressed as a patch — see `planSubstringEdit`.
|
|
79
|
+
* - Renames are rejected by OpenCode 2.0's implementation ("apply_patch moves
|
|
80
|
+
* are not supported yet"), so `*** Move to:` is never emitted here.
|
|
81
|
+
*/
|
|
82
|
+
export declare const APPLY_PATCH_TOOL = "apply_patch";
|
|
83
|
+
/** One `@@` chunk: replace `oldLines` with `newLines`. */
|
|
84
|
+
export type UpdateChunk = {
|
|
85
|
+
oldLines: string[];
|
|
86
|
+
newLines: string[];
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Whole-file write as `*** Add File:`. Valid whether or not the target exists —
|
|
90
|
+
* OpenCode's add case overwrites unconditionally.
|
|
91
|
+
*/
|
|
92
|
+
export declare function buildAddFilePatch(filePath: string, content: string): string;
|
|
93
|
+
/** Targeted replacement as `*** Update File:` with one `@@` chunk per change. */
|
|
94
|
+
export declare function buildUpdateFilePatch(filePath: string, chunks: UpdateChunk[]): string;
|
|
95
|
+
export type SubstringEditPlan = {
|
|
96
|
+
ok: true;
|
|
97
|
+
chunks: UpdateChunk[];
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
reason: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Expand a substring replacement to the whole lines it touches, because
|
|
104
|
+
* `apply_patch` matches line sequences rather than substrings.
|
|
105
|
+
*
|
|
106
|
+
* Refuses rather than guessing: an absent match, or an ambiguous one without
|
|
107
|
+
* `replaceAll`, would otherwise be silently applied to the wrong region.
|
|
108
|
+
*/
|
|
109
|
+
export declare function planSubstringEdit(source: string, oldString: string, newString: string, replaceAll?: boolean): SubstringEditPlan;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesis of OpenCode `apply_patch` envelopes from Cursor's native write/edit
|
|
3
|
+
* exec requests.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* OpenCode 1.x does not merely *add* `apply_patch` for GPT models — it removes
|
|
8
|
+
* `edit` and `write` from the catalog in exchange. From
|
|
9
|
+
* `packages/opencode/src/tool/registry.ts` (`ToolRegistry.tools`):
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // use apply tool in same format as codex
|
|
13
|
+
* const usePatch =
|
|
14
|
+
* input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4")
|
|
15
|
+
* if (tool.id === ApplyPatchTool.id) return usePatch
|
|
16
|
+
* if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* The predicate runs on the *API* model id, so every Cursor `gpt-5*` model
|
|
20
|
+
* matches (and, as a quirk of substring matching, `gpt-3.5-*` does too while
|
|
21
|
+
* `gpt-4o` does not).
|
|
22
|
+
*
|
|
23
|
+
* Upstream context, in case this ever needs revisiting:
|
|
24
|
+
*
|
|
25
|
+
* - Introduced by opencode PR #9127 ("apply_patch tool for openai models"). The
|
|
26
|
+
* intent is to hand GPT/Codex models the tool surface they were post-trained
|
|
27
|
+
* on; the same commit tells the codex system prompt to prefer `apply_patch`.
|
|
28
|
+
* - The *exclusivity* is deliberate. On opencode issue #34491 a maintainer
|
|
29
|
+
* describes the gate as a "band-aid", "intentionally minimal", whose purpose
|
|
30
|
+
* is that "GPT no longer receives edit + write + apply_patch simultaneously",
|
|
31
|
+
* and notes that a plugin-side opt-out "isn't feasible today".
|
|
32
|
+
* - opencode issue #19942 reported exactly this hazard — model-conditional tool
|
|
33
|
+
* substitution makes model-agnostic integrations impossible — and was closed
|
|
34
|
+
* `not_planned` by a stale bot.
|
|
35
|
+
* - opencode issue #35408 (open, milestone 2.0) proposes a
|
|
36
|
+
* `ctx.session.context(...)` hook exposing a mutable tool roster, and lists
|
|
37
|
+
* "Select `apply_patch` versus `edit` + `write`" as work to move out of the
|
|
38
|
+
* registry. Once that lands, a host-side opt-out replaces this shim.
|
|
39
|
+
* - OpenCode 2.0 (`packages/core`) has **no** such gate: `apply_patch`, `edit`
|
|
40
|
+
* and `write` are all registered unconditionally in `tool/builtins.ts`. This
|
|
41
|
+
* translation is therefore 1.x-only in practice, but it is keyed off the
|
|
42
|
+
* advertised set rather than a version check, so it simply never fires on 2.0.
|
|
43
|
+
*
|
|
44
|
+
* ## Why translation, not advertisement
|
|
45
|
+
*
|
|
46
|
+
* `toolsToDescriptors` forwards every advertised host tool to Cursor verbatim,
|
|
47
|
+
* so `opencode-apply_patch` already appears in Cursor's catalog whenever
|
|
48
|
+
* OpenCode advertises it, and an `mcp_args` call naming it already executes.
|
|
49
|
+
* What breaks is Cursor's *native* write/edit channel (`write_args`,
|
|
50
|
+
* `pi_edit_args`), which this provider resolves to the fixed host names
|
|
51
|
+
* `write`/`edit`.
|
|
52
|
+
*
|
|
53
|
+
* Translating on our side is the only option: Cursor's agent protocol has no
|
|
54
|
+
* patch-format edit tool at all (its `ToolCall` oneof offers whole-file
|
|
55
|
+
* `edit_tool_call`/`write_args` and substring `pi_edit_tool_call`/`pi_edit_args`;
|
|
56
|
+
* `apply_agent_diff_tool_call` is unrelated — it applies a cloud agent's diff by
|
|
57
|
+
* `agent_id`). Cursor's CLI also contains no model-conditional branching
|
|
58
|
+
* whatsoever, and `ModelDetails` carries no tool-capability flags, so the same
|
|
59
|
+
* native requests arrive for every model. Those names are absent from
|
|
60
|
+
* the catalog, so the request is refused before it reaches OpenCode.
|
|
61
|
+
*
|
|
62
|
+
* ## Format notes (verified against `packages/opencode/src/patch/index.ts`)
|
|
63
|
+
*
|
|
64
|
+
* - `*** Add File:` **overwrites an existing file** — the tool's `add` case uses
|
|
65
|
+
* `oldContent = ""` and never stats the target (upstream test: "adds file
|
|
66
|
+
* overwriting existing file"). A whole-file write therefore needs no diff and
|
|
67
|
+
* no existence check.
|
|
68
|
+
* - An empty `@@` header is a valid chunk start: the parser stores
|
|
69
|
+
* `change_context: contextLine || undefined`, and a missing context simply
|
|
70
|
+
* means "match `old_lines` from the current scan position".
|
|
71
|
+
* - Every payload line is prefixed with `+`, `-`, or a space, and the parser
|
|
72
|
+
* only breaks on *unprefixed* `***` / `@@`. Content lines that themselves
|
|
73
|
+
* begin with `***` or `@@` therefore survive a round trip unharmed.
|
|
74
|
+
* - `Add File` content is rejoined with `\n` and normalized to exactly one
|
|
75
|
+
* trailing newline. A write of content with no trailing newline gains one.
|
|
76
|
+
* - Update chunks match **whole lines** via `seekSequence`, unlike OpenCode
|
|
77
|
+
* `edit`, which is substring-based. Substring edits must be expanded to line
|
|
78
|
+
* boundaries before they can be expressed as a patch — see `planSubstringEdit`.
|
|
79
|
+
* - Renames are rejected by OpenCode 2.0's implementation ("apply_patch moves
|
|
80
|
+
* are not supported yet"), so `*** Move to:` is never emitted here.
|
|
81
|
+
*/
|
|
82
|
+
export const APPLY_PATCH_TOOL = "apply_patch";
|
|
83
|
+
const BEGIN = "*** Begin Patch";
|
|
84
|
+
const END = "*** End Patch";
|
|
85
|
+
/**
|
|
86
|
+
* Whole-file write as `*** Add File:`. Valid whether or not the target exists —
|
|
87
|
+
* OpenCode's add case overwrites unconditionally.
|
|
88
|
+
*/
|
|
89
|
+
export function buildAddFilePatch(filePath, content) {
|
|
90
|
+
const lines = splitLines(content);
|
|
91
|
+
return [BEGIN, `*** Add File: ${filePath}`, ...lines.map((line) => `+${line}`), END].join("\n");
|
|
92
|
+
}
|
|
93
|
+
/** Targeted replacement as `*** Update File:` with one `@@` chunk per change. */
|
|
94
|
+
export function buildUpdateFilePatch(filePath, chunks) {
|
|
95
|
+
const body = [];
|
|
96
|
+
for (const chunk of chunks) {
|
|
97
|
+
body.push("@@");
|
|
98
|
+
for (const line of chunk.oldLines)
|
|
99
|
+
body.push(`-${line}`);
|
|
100
|
+
for (const line of chunk.newLines)
|
|
101
|
+
body.push(`+${line}`);
|
|
102
|
+
}
|
|
103
|
+
return [BEGIN, `*** Update File: ${filePath}`, ...body, END].join("\n");
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Expand a substring replacement to the whole lines it touches, because
|
|
107
|
+
* `apply_patch` matches line sequences rather than substrings.
|
|
108
|
+
*
|
|
109
|
+
* Refuses rather than guessing: an absent match, or an ambiguous one without
|
|
110
|
+
* `replaceAll`, would otherwise be silently applied to the wrong region.
|
|
111
|
+
*/
|
|
112
|
+
export function planSubstringEdit(source, oldString, newString, replaceAll = false) {
|
|
113
|
+
if (oldString === "")
|
|
114
|
+
return { ok: false, reason: "the text to replace is empty" };
|
|
115
|
+
const offsets = [];
|
|
116
|
+
for (let at = source.indexOf(oldString); at !== -1; at = source.indexOf(oldString, at + oldString.length)) {
|
|
117
|
+
offsets.push(at);
|
|
118
|
+
}
|
|
119
|
+
if (offsets.length === 0)
|
|
120
|
+
return { ok: false, reason: "the text to replace was not found in the file" };
|
|
121
|
+
if (offsets.length > 1 && !replaceAll) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: `the text to replace appears ${offsets.length} times; it must be unique`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
const targets = replaceAll ? offsets : offsets.slice(0, 1);
|
|
128
|
+
const chunks = [];
|
|
129
|
+
let previousEnd = -1;
|
|
130
|
+
for (const offset of targets) {
|
|
131
|
+
const start = lineStart(source, offset);
|
|
132
|
+
const end = lineEnd(source, offset + oldString.length);
|
|
133
|
+
// Overlapping expansions would emit the same original lines twice, and the
|
|
134
|
+
// second chunk could never match after the first replaced them.
|
|
135
|
+
if (start < previousEnd) {
|
|
136
|
+
return { ok: false, reason: "overlapping replacements cannot be expressed as a patch" };
|
|
137
|
+
}
|
|
138
|
+
previousEnd = end;
|
|
139
|
+
const before = source.slice(start, offset);
|
|
140
|
+
const after = source.slice(offset + oldString.length, end);
|
|
141
|
+
chunks.push({
|
|
142
|
+
oldLines: splitLines(source.slice(start, end)),
|
|
143
|
+
newLines: splitLines(before + newString + after),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, chunks };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Split into patch payload lines. A trailing newline is dropped so it is not
|
|
150
|
+
* emitted as a spurious empty line — OpenCode re-adds exactly one on apply.
|
|
151
|
+
*/
|
|
152
|
+
function splitLines(text) {
|
|
153
|
+
const normalized = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
154
|
+
return normalized.split("\n");
|
|
155
|
+
}
|
|
156
|
+
function lineStart(source, offset) {
|
|
157
|
+
const at = source.lastIndexOf("\n", offset - 1);
|
|
158
|
+
return at === -1 ? 0 : at + 1;
|
|
159
|
+
}
|
|
160
|
+
function lineEnd(source, offset) {
|
|
161
|
+
const at = source.indexOf("\n", offset);
|
|
162
|
+
return at === -1 ? source.length : at;
|
|
163
|
+
}
|
|
@@ -434,15 +434,22 @@ export function createMessageTypes() {
|
|
|
434
434
|
{ id: 1, name: "success", type: "GrepSuccess" },
|
|
435
435
|
{ id: 2, name: "error", type: "GrepError" },
|
|
436
436
|
], [{ name: "result", fields: ["success", "error"] }]);
|
|
437
|
+
// agent.v1.WriteArgs. Cursor's own LocalWriteExecutor reads `file_bytes`
|
|
438
|
+
// first and only falls back to `file_text`, so a write whose content arrives
|
|
439
|
+
// as bytes decodes to an empty string unless field 5 is declared here.
|
|
437
440
|
addType(root, "WriteArgs", [
|
|
438
441
|
{ id: 1, name: "path", type: "string" },
|
|
439
442
|
{ id: 2, name: "file_text", type: "string" },
|
|
440
443
|
{ id: 3, name: "tool_call_id", type: "string" },
|
|
444
|
+
{ id: 4, name: "return_file_content_after_write", type: "bool" },
|
|
445
|
+
{ id: 5, name: "file_bytes", type: "bytes" },
|
|
446
|
+
{ id: 6, name: "encoding_hint", type: "string" },
|
|
441
447
|
]);
|
|
442
448
|
addType(root, "WriteSuccess", [
|
|
443
449
|
{ id: 1, name: "path", type: "string" },
|
|
444
450
|
{ id: 2, name: "lines_created", type: "int32" },
|
|
445
451
|
{ id: 3, name: "file_size", type: "int32" },
|
|
452
|
+
{ id: 4, name: "file_content_after_write", type: "string" },
|
|
446
453
|
]);
|
|
447
454
|
addType(root, "WriteError", [
|
|
448
455
|
{ id: 1, name: "path", type: "string" },
|
|
@@ -476,7 +483,26 @@ export function createMessageTypes() {
|
|
|
476
483
|
{ id: 2, name: "error", type: "PiWriteError" },
|
|
477
484
|
{ id: 3, name: "rejected", type: "PiWriteRejected" },
|
|
478
485
|
], [{ name: "result", fields: ["success", "error", "rejected"] }]);
|
|
479
|
-
|
|
486
|
+
// agent.v1.PiTruncation. Every Pi *ExecSuccess shares output(1)+truncation(2)
|
|
487
|
+
// and only diverges at field 3, so the shared PiOutputSuccess can carry it.
|
|
488
|
+
// Cursor's own CLI always reports truncation rather than silently shortening
|
|
489
|
+
// a payload; emitting this keeps a capped read distinguishable from a
|
|
490
|
+
// complete one.
|
|
491
|
+
addType(root, "PiTruncation", [
|
|
492
|
+
{ id: 1, name: "truncated", type: "bool" },
|
|
493
|
+
{ id: 2, name: "truncated_by", type: "string" },
|
|
494
|
+
{ id: 3, name: "total_lines", type: "uint32" },
|
|
495
|
+
{ id: 4, name: "output_lines", type: "uint32" },
|
|
496
|
+
{ id: 5, name: "output_bytes", type: "uint32" },
|
|
497
|
+
{ id: 6, name: "max_lines", type: "uint32" },
|
|
498
|
+
{ id: 7, name: "max_bytes", type: "uint32" },
|
|
499
|
+
{ id: 8, name: "first_line_exceeds_limit", type: "bool" },
|
|
500
|
+
{ id: 9, name: "last_line_partial", type: "bool" },
|
|
501
|
+
]);
|
|
502
|
+
addType(root, "PiOutputSuccess", [
|
|
503
|
+
{ id: 1, name: "output", type: "string" },
|
|
504
|
+
{ id: 2, name: "truncation", type: "PiTruncation" },
|
|
505
|
+
]);
|
|
480
506
|
addType(root, "PiExecError", [{ id: 1, name: "error", type: "string" }]);
|
|
481
507
|
addType(root, "PiExecRejected", [{ id: 1, name: "reason", type: "string" }]);
|
|
482
508
|
for (const resultType of [
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -87,6 +87,22 @@ export declare function resolveCursorSubagentType(subagentType: string, catalog?
|
|
|
87
87
|
export declare function mapCursorSubagentTypeToOpenCode(subagentType: string): string;
|
|
88
88
|
/** Resolve a native Cursor subagent request to this turn's executor and catalog. */
|
|
89
89
|
export declare function remapNativeSubagentForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, catalog?: HostSubagentCatalog): void;
|
|
90
|
+
/**
|
|
91
|
+
* Express a native Cursor write/edit as `apply_patch` when the host swapped the
|
|
92
|
+
* edit-tool family out from under us.
|
|
93
|
+
*
|
|
94
|
+
* OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
|
|
95
|
+
* advertises `apply_patch` in their place — see the module comment in
|
|
96
|
+
* `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
|
|
97
|
+
* using its native write/edit exec channel regardless, so without this the
|
|
98
|
+
* request is refused as an unavailable tool and the model loses the ability to
|
|
99
|
+
* change files at all.
|
|
100
|
+
*
|
|
101
|
+
* Keyed purely off the advertised set: this is inert whenever the host offers
|
|
102
|
+
* `write`/`edit` normally, and equally inert when it offers neither those nor
|
|
103
|
+
* `apply_patch` (the caller's unavailable-tool rejection still applies).
|
|
104
|
+
*/
|
|
105
|
+
export declare function remapEditToolsForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, workspaceRoot?: string): void;
|
|
90
106
|
export type ParsedExecRequest = {
|
|
91
107
|
id: number;
|
|
92
108
|
execId: string;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildEnv } from "../context/env.js";
|
|
|
7
7
|
import { ensureOpencodeProjectDir } from "../context/paths.js";
|
|
8
8
|
import { trace, traceRequestContextPaths } from "../debug.js";
|
|
9
9
|
import { cursorExecVariantByRequestName } from "./exec-variants.js";
|
|
10
|
+
import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
|
|
10
11
|
import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
|
|
11
12
|
// Exec variant field number whose reply is the server-initiated request_context
|
|
12
13
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
@@ -260,11 +261,15 @@ const CURSOR_INTERNAL_KEYS = new Set([
|
|
|
260
261
|
"close_stdin",
|
|
261
262
|
"output_notification",
|
|
262
263
|
"file_output_threshold_bytes",
|
|
264
|
+
// WriteArgs #4. A request for WriteSuccess.file_content_after_write, which we
|
|
265
|
+
// do not populate; harmless to omit, but it is not tool input.
|
|
263
266
|
"return_file_content_after_write",
|
|
264
|
-
"file_bytes",
|
|
265
|
-
"encoding_hint",
|
|
266
267
|
"ignore",
|
|
267
268
|
]);
|
|
269
|
+
// WriteArgs #5/#6 are deliberately NOT internal: `file_bytes` carries the file
|
|
270
|
+
// content itself whenever Cursor sends it instead of `file_text` (its own
|
|
271
|
+
// LocalWriteExecutor prefers bytes when non-empty), and `encoding_hint` is how
|
|
272
|
+
// that payload is decoded. The `write` mapping consumes both explicitly.
|
|
268
273
|
/** Required content fields where an empty string is meaningful (for example, truncating a file). */
|
|
269
274
|
const PRESERVE_EMPTY_STRING_KEYS = new Set([
|
|
270
275
|
"content",
|
|
@@ -476,6 +481,92 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
476
481
|
},
|
|
477
482
|
};
|
|
478
483
|
}
|
|
484
|
+
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
485
|
+
const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
486
|
+
/**
|
|
487
|
+
* Express a native Cursor write/edit as `apply_patch` when the host swapped the
|
|
488
|
+
* edit-tool family out from under us.
|
|
489
|
+
*
|
|
490
|
+
* OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
|
|
491
|
+
* advertises `apply_patch` in their place — see the module comment in
|
|
492
|
+
* `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
|
|
493
|
+
* using its native write/edit exec channel regardless, so without this the
|
|
494
|
+
* request is refused as an unavailable tool and the model loses the ability to
|
|
495
|
+
* change files at all.
|
|
496
|
+
*
|
|
497
|
+
* Keyed purely off the advertised set: this is inert whenever the host offers
|
|
498
|
+
* `write`/`edit` normally, and equally inert when it offers neither those nor
|
|
499
|
+
* `apply_patch` (the caller's unavailable-tool rejection still applies).
|
|
500
|
+
*/
|
|
501
|
+
export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
|
|
502
|
+
if (parsed.toolName !== "write" && parsed.toolName !== "edit")
|
|
503
|
+
return;
|
|
504
|
+
const advertised = new Set(advertisedToolNames);
|
|
505
|
+
if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
|
|
506
|
+
return;
|
|
507
|
+
const requested = parsed.toolName;
|
|
508
|
+
const filePath = str(parsed.args.filePath);
|
|
509
|
+
const refuse = (reason) => {
|
|
510
|
+
parsed.toolName = APPLY_PATCH_TOOL;
|
|
511
|
+
parsed.args = {};
|
|
512
|
+
parsed.localError =
|
|
513
|
+
`Cursor ${requested} request cannot be expressed as an apply_patch call: ${reason}. ` +
|
|
514
|
+
"The host advertises `apply_patch` instead of `edit`/`write` for this model.";
|
|
515
|
+
};
|
|
516
|
+
if (!filePath) {
|
|
517
|
+
refuse("no target path was provided");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let patchText;
|
|
521
|
+
if (requested === "write") {
|
|
522
|
+
const content = stringValue(parsed.args.content);
|
|
523
|
+
if (content === undefined) {
|
|
524
|
+
refuse("no file content was provided");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// `*** Add File:` overwrites an existing target, so a whole-file write maps
|
|
528
|
+
// directly without reading the current contents or diffing.
|
|
529
|
+
patchText = buildAddFilePatch(filePath, content);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
const oldString = stringValue(parsed.args.oldString);
|
|
533
|
+
const newString = stringValue(parsed.args.newString);
|
|
534
|
+
if (oldString === undefined || newString === undefined) {
|
|
535
|
+
refuse("the replacement is missing its old or new text");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// apply_patch matches whole lines, so a substring edit has to be widened to
|
|
539
|
+
// the lines it touches — which requires the file the model is editing.
|
|
540
|
+
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
541
|
+
let source;
|
|
542
|
+
try {
|
|
543
|
+
// Bounded because this read is synchronous on the Run pump. Cursor's own
|
|
544
|
+
// executor uses the same 50 MB threshold before reading a file it is
|
|
545
|
+
// about to write.
|
|
546
|
+
const size = fs.statSync(absolute).size;
|
|
547
|
+
if (size > MAX_EDIT_SOURCE_BYTES) {
|
|
548
|
+
refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
source = fs.readFileSync(absolute, "utf8");
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
refuse(`the target file could not be read (${e.message})`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const plan = planSubstringEdit(source, oldString, newString, parsed.args.replaceAll === true);
|
|
558
|
+
if (!plan.ok) {
|
|
559
|
+
refuse(plan.reason);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
patchText = buildUpdateFilePatch(filePath, plan.chunks);
|
|
563
|
+
}
|
|
564
|
+
parsed.toolName = APPLY_PATCH_TOOL;
|
|
565
|
+
parsed.args = { patchText };
|
|
566
|
+
// apply_patch's output does not carry opencode `write`'s <path> tag, so keep
|
|
567
|
+
// the requested path for the typed Cursor result.
|
|
568
|
+
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
569
|
+
}
|
|
479
570
|
export function parseExecServerMessage(msg) {
|
|
480
571
|
const id = msg.id;
|
|
481
572
|
if (id === undefined)
|
|
@@ -707,7 +798,13 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
707
798
|
const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
|
|
708
799
|
if (filePath)
|
|
709
800
|
args.filePath = filePath;
|
|
710
|
-
|
|
801
|
+
// Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
|
|
802
|
+
// non-empty and only falls back to `file_text`; mirror that order so a
|
|
803
|
+
// byte-encoded write is not silently seen as empty content.
|
|
804
|
+
const bytes = bytesValue(cleaned.file_bytes) ?? bytesValue(cleaned.fileBytes);
|
|
805
|
+
const content = bytes !== undefined
|
|
806
|
+
? decodeWriteBytes(bytes, str(cleaned.encoding_hint) ?? str(cleaned.encodingHint))
|
|
807
|
+
: stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
|
|
711
808
|
if (content !== undefined)
|
|
712
809
|
args.content = content;
|
|
713
810
|
return { toolName: "write", args };
|
|
@@ -781,6 +878,32 @@ function str(v) {
|
|
|
781
878
|
function stringValue(v) {
|
|
782
879
|
return typeof v === "string" ? v : undefined;
|
|
783
880
|
}
|
|
881
|
+
/** A non-empty protobuf `bytes` field, or undefined when absent/empty. */
|
|
882
|
+
function bytesValue(v) {
|
|
883
|
+
if (v instanceof Uint8Array)
|
|
884
|
+
return v.length > 0 ? v : undefined;
|
|
885
|
+
if (Array.isArray(v) && v.every((b) => typeof b === "number")) {
|
|
886
|
+
return v.length > 0 ? Uint8Array.from(v) : undefined;
|
|
887
|
+
}
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Decode `WriteArgs.file_bytes`. `encoding_hint` names the file's original
|
|
892
|
+
* encoding; OpenCode's `write` takes a string, so anything Node cannot decode
|
|
893
|
+
* falls back to UTF-8 rather than dropping the write.
|
|
894
|
+
*/
|
|
895
|
+
function decodeWriteBytes(bytes, encodingHint) {
|
|
896
|
+
const encoding = encodingHint?.trim().toLowerCase().replace(/[_ ]/g, "-");
|
|
897
|
+
if (encoding && encoding !== "utf-8" && encoding !== "utf8") {
|
|
898
|
+
try {
|
|
899
|
+
return new TextDecoder(encoding, { fatal: false }).decode(bytes);
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
trace(`write: unsupported encoding_hint ${JSON.stringify(encodingHint)} — decoding as utf-8`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
906
|
+
}
|
|
784
907
|
function num(v) {
|
|
785
908
|
if (typeof v === "number" && Number.isFinite(v))
|
|
786
909
|
return v;
|
|
@@ -1076,16 +1199,22 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1076
1199
|
return { error: { path: readPath, error } };
|
|
1077
1200
|
// Strip opencode's <path>/<content> envelope so Cursor's model receives
|
|
1078
1201
|
// raw file content and can't echo the wrapper into subsequent writes.
|
|
1079
|
-
//
|
|
1202
|
+
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1203
|
+
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1204
|
+
// native channel, so this is the path that matters in practice.
|
|
1080
1205
|
const content = unwrapReadOutput(output);
|
|
1081
1206
|
const statPath = extractPathTag(output) ?? readPath;
|
|
1082
1207
|
const outputMetadata = parseOpenCodeReadMetadata(output);
|
|
1083
1208
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1084
1209
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1210
|
+
// `truncated` alone is not enough: it is set here, and models still assert
|
|
1211
|
+
// the partial content is the whole file. Cursor's own executor puts the
|
|
1212
|
+
// limit marker in the output text for the same reason, so append one.
|
|
1213
|
+
const notice = readTruncationNotice(output, resultMetadata);
|
|
1085
1214
|
return {
|
|
1086
1215
|
success: {
|
|
1087
1216
|
path: readPath,
|
|
1088
|
-
content,
|
|
1217
|
+
content: notice ? `${content}\n\n${notice}` : content,
|
|
1089
1218
|
total_lines: totalLines,
|
|
1090
1219
|
file_size: readFileSize(statPath),
|
|
1091
1220
|
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
@@ -1117,25 +1246,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1117
1246
|
},
|
|
1118
1247
|
};
|
|
1119
1248
|
}
|
|
1120
|
-
case "write_result":
|
|
1249
|
+
case "write_result": {
|
|
1250
|
+
// `apply_patch` output carries no <path> tag, so prefer the path recorded
|
|
1251
|
+
// when the request was remapped away from `write`.
|
|
1252
|
+
const remappedPath = str(resultMetadata?.path);
|
|
1121
1253
|
if (error)
|
|
1122
|
-
return { error: { path: "", error } };
|
|
1254
|
+
return { error: { path: remappedPath ?? "", error } };
|
|
1123
1255
|
return {
|
|
1124
1256
|
success: {
|
|
1125
|
-
path: extractPathTag(output) ?? "",
|
|
1257
|
+
path: remappedPath ?? extractPathTag(output) ?? "",
|
|
1126
1258
|
lines_created: countLines(output),
|
|
1127
1259
|
file_size: output.length,
|
|
1128
1260
|
},
|
|
1129
1261
|
};
|
|
1262
|
+
}
|
|
1130
1263
|
case "pi_write_result":
|
|
1131
1264
|
// PiWriteExecSuccess is just { output }; error is { error }.
|
|
1132
1265
|
if (error)
|
|
1133
1266
|
return { error: { error } };
|
|
1134
1267
|
return { success: { output: output || "Wrote file successfully." } };
|
|
1135
|
-
case "pi_read_result":
|
|
1268
|
+
case "pi_read_result": {
|
|
1136
1269
|
if (error)
|
|
1137
1270
|
return { error: { error } };
|
|
1138
|
-
|
|
1271
|
+
// Pi results carry truncation structurally (PiReadExecSuccess field 2),
|
|
1272
|
+
// which is how Cursor's own executors report a capped payload.
|
|
1273
|
+
const content = unwrapReadOutput(output);
|
|
1274
|
+
const truncation = readTruncationMessage(output, content);
|
|
1275
|
+
return { success: { output: content, ...(truncation ? { truncation } : {}) } };
|
|
1276
|
+
}
|
|
1139
1277
|
case "pi_bash_result":
|
|
1140
1278
|
case "pi_edit_result":
|
|
1141
1279
|
case "pi_grep_result":
|
|
@@ -1204,21 +1342,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1204
1342
|
},
|
|
1205
1343
|
};
|
|
1206
1344
|
}
|
|
1207
|
-
case "mcp_result":
|
|
1345
|
+
case "mcp_result": {
|
|
1208
1346
|
if (error)
|
|
1209
1347
|
return { error: { error } };
|
|
1210
1348
|
// opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
|
|
1211
1349
|
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
1212
1350
|
// "read" so a non-read MCP tool whose output merely contains a
|
|
1213
1351
|
// "<content>"-like block is never rewritten.
|
|
1352
|
+
if (toolName !== "read") {
|
|
1353
|
+
return { success: { content: [{ text: { text: output } }], is_error: false } };
|
|
1354
|
+
}
|
|
1355
|
+
// Carry the truncation notice as its own content item: the file content
|
|
1356
|
+
// item stays byte-exact, so it can still never be echoed into a write.
|
|
1357
|
+
const notice = readTruncationNotice(output);
|
|
1214
1358
|
return {
|
|
1215
1359
|
success: {
|
|
1216
1360
|
content: [
|
|
1217
|
-
{ text: { text:
|
|
1361
|
+
{ text: { text: unwrapReadOutput(output) } },
|
|
1362
|
+
...(notice ? [{ text: { text: notice } }] : []),
|
|
1218
1363
|
],
|
|
1219
1364
|
is_error: false,
|
|
1220
1365
|
},
|
|
1221
1366
|
};
|
|
1367
|
+
}
|
|
1222
1368
|
case "subagent_result": {
|
|
1223
1369
|
const task = parseOpenCodeTaskOutput(output);
|
|
1224
1370
|
if (error || task.state === "error") {
|
|
@@ -1270,22 +1416,114 @@ function extractPathTag(output) {
|
|
|
1270
1416
|
const m = output.match(/<path>([^<]+)<\/path>/);
|
|
1271
1417
|
return m?.[1];
|
|
1272
1418
|
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Isolate opencode's read footer: the last blank-line-separated block before
|
|
1421
|
+
* the closing `</content>`. Scanning the whole envelope would let a file that
|
|
1422
|
+
* merely *quotes* a footer (this repository's own docs and tests do) pass for a
|
|
1423
|
+
* truncated read. The body can never contain a blank line — every source line
|
|
1424
|
+
* is rendered as `N: …`, so even an empty line keeps its `N: ` prefix.
|
|
1425
|
+
*
|
|
1426
|
+
* Falls back to the full string when the envelope is absent, preserving
|
|
1427
|
+
* behavior for non-standard read output.
|
|
1428
|
+
*/
|
|
1429
|
+
function readEnvelopeFooter(output) {
|
|
1430
|
+
const close = output.lastIndexOf("\n</content>");
|
|
1431
|
+
if (close === -1)
|
|
1432
|
+
return output;
|
|
1433
|
+
const start = output.lastIndexOf("\n\n", close);
|
|
1434
|
+
if (start === -1)
|
|
1435
|
+
return output;
|
|
1436
|
+
return output.slice(start + 2, close);
|
|
1437
|
+
}
|
|
1273
1438
|
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
1274
1439
|
function parseOpenCodeReadMetadata(output) {
|
|
1275
|
-
const
|
|
1440
|
+
const footer = readEnvelopeFooter(output);
|
|
1441
|
+
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
|
|
1276
1442
|
if (showing) {
|
|
1277
1443
|
return {
|
|
1278
1444
|
startLine: Number(showing[1]),
|
|
1279
1445
|
endLine: Number(showing[2]),
|
|
1280
1446
|
...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
|
|
1281
|
-
outputCapped:
|
|
1447
|
+
outputCapped: footer.includes("(Output capped at "),
|
|
1282
1448
|
};
|
|
1283
1449
|
}
|
|
1284
|
-
const complete = /\(End of file - total (\d+) lines?\)/.exec(
|
|
1450
|
+
const complete = /\(End of file - total (\d+) lines?\)/.exec(footer);
|
|
1285
1451
|
if (complete)
|
|
1286
1452
|
return { totalLines: Number(complete[1]) };
|
|
1287
1453
|
return {};
|
|
1288
1454
|
}
|
|
1455
|
+
/**
|
|
1456
|
+
* OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
|
|
1457
|
+
* and cuts on a whole-line boundary, then appends
|
|
1458
|
+
* "(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)".
|
|
1459
|
+
*
|
|
1460
|
+
* `unwrapReadOutput` strips that footer along with the envelope, deliberately:
|
|
1461
|
+
* Cursor's model echoes whatever it is handed straight back into the next
|
|
1462
|
+
* write, so anything left in the content stream can end up written into the
|
|
1463
|
+
* file. But dropping the notice with no replacement is worse — the model then
|
|
1464
|
+
* believes a capped read is the complete file, rewrites it from what it has,
|
|
1465
|
+
* and everything past the cap is destroyed.
|
|
1466
|
+
*
|
|
1467
|
+
* Cursor's own CLI never truncates silently. Its local executor annotates every
|
|
1468
|
+
* capped payload ("50KB limit reached", "[Showing last …KB of line N (50KB
|
|
1469
|
+
* limit). Full output: …]") and returns structured truncation metadata. Mirror
|
|
1470
|
+
* that, keeping the signal out of the file content itself so it still cannot be
|
|
1471
|
+
* echoed into a write.
|
|
1472
|
+
*/
|
|
1473
|
+
function readTruncationSummary(output) {
|
|
1474
|
+
const meta = parseOpenCodeReadMetadata(output);
|
|
1475
|
+
if (meta.startLine === undefined || meta.endLine === undefined)
|
|
1476
|
+
return undefined;
|
|
1477
|
+
// A complete read reports "(End of file …)" and never reaches this shape.
|
|
1478
|
+
if (meta.totalLines !== undefined && meta.endLine >= meta.totalLines && !meta.outputCapped)
|
|
1479
|
+
return undefined;
|
|
1480
|
+
return {
|
|
1481
|
+
startLine: meta.startLine,
|
|
1482
|
+
endLine: meta.endLine,
|
|
1483
|
+
nextOffset: meta.endLine + 1,
|
|
1484
|
+
capped: meta.outputCapped === true,
|
|
1485
|
+
...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Model-visible replacement for the stripped footer.
|
|
1490
|
+
*
|
|
1491
|
+
* Only for reads the model did not ask to bound. A caller that passed an
|
|
1492
|
+
* explicit offset/limit already knows it asked for a slice — warning there
|
|
1493
|
+
* would cry wolf on every deliberate paged read.
|
|
1494
|
+
*/
|
|
1495
|
+
function readTruncationNotice(output, resultMetadata) {
|
|
1496
|
+
const summary = readTruncationSummary(output);
|
|
1497
|
+
if (!summary)
|
|
1498
|
+
return undefined;
|
|
1499
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
1500
|
+
if (rangeRequested && !summary.capped)
|
|
1501
|
+
return undefined;
|
|
1502
|
+
const range = summary.totalLines !== undefined
|
|
1503
|
+
? `lines ${summary.startLine}-${summary.endLine} of ${summary.totalLines}`
|
|
1504
|
+
: `lines ${summary.startLine}-${summary.endLine}`;
|
|
1505
|
+
return (`[Partial read: the content above is ${range}` +
|
|
1506
|
+
(summary.capped ? ", capped at the host's 50 KB output limit" : "") +
|
|
1507
|
+
`. It is NOT the complete file. Continue with offset=${summary.nextOffset} before ` +
|
|
1508
|
+
`acting on the whole file; writing the content above back would delete everything ` +
|
|
1509
|
+
`after line ${summary.endLine}.]`);
|
|
1510
|
+
}
|
|
1511
|
+
/** agent.v1.PiTruncation for a capped OpenCode read. */
|
|
1512
|
+
function readTruncationMessage(output, content) {
|
|
1513
|
+
const summary = readTruncationSummary(output);
|
|
1514
|
+
if (!summary)
|
|
1515
|
+
return undefined;
|
|
1516
|
+
return {
|
|
1517
|
+
truncated: true,
|
|
1518
|
+
truncated_by: summary.capped ? "bytes" : "lines",
|
|
1519
|
+
...(summary.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
|
|
1520
|
+
output_lines: Math.max(0, summary.endLine - summary.startLine + 1),
|
|
1521
|
+
output_bytes: Buffer.byteLength(content, "utf8"),
|
|
1522
|
+
...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
/** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
|
|
1526
|
+
const OPENCODE_READ_MAX_BYTES = 50 * 1024;
|
|
1289
1527
|
function readRangeApplied(resultMetadata, totalLines) {
|
|
1290
1528
|
const offset = num(resultMetadata?.offset);
|
|
1291
1529
|
const limit = num(resultMetadata?.limit);
|
package/dist/shell-timeout.js
CHANGED
|
@@ -322,8 +322,12 @@ function lastPrivateMarker(output, marker, valuePattern) {
|
|
|
322
322
|
return last;
|
|
323
323
|
}
|
|
324
324
|
function parseOpenCodeTimeout(output) {
|
|
325
|
-
const
|
|
326
|
-
const
|
|
325
|
+
const closeTag = "</shell_metadata>";
|
|
326
|
+
const closeAt = output.lastIndexOf(closeTag);
|
|
327
|
+
if (closeAt === -1 || output.slice(closeAt + closeTag.length).trim() !== "")
|
|
328
|
+
return undefined;
|
|
329
|
+
const header = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\./;
|
|
330
|
+
const match = header.exec(output.slice(0, closeAt));
|
|
327
331
|
if (!match || match.index === undefined)
|
|
328
332
|
return undefined;
|
|
329
333
|
return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"prepublishOnly": "bun run build"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@ai-sdk/provider": "3.0.
|
|
65
|
-
"protobufjs": "^7.
|
|
64
|
+
"@ai-sdk/provider": "3.0.14",
|
|
65
|
+
"protobufjs": "^7.6.5"
|
|
66
66
|
},
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@opencode-ai/plugin": "^1.17.13",
|
|
@@ -74,10 +74,10 @@
|
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@opencode-ai/plugin": "^1.
|
|
78
|
-
"@opencode-ai/plugin-next": "
|
|
79
|
-
"@tsconfig/node22": "^22.0.
|
|
80
|
-
"@types/node": "^22.
|
|
81
|
-
"typescript": "^5.
|
|
77
|
+
"@opencode-ai/plugin": "^1.18.13",
|
|
78
|
+
"@opencode-ai/plugin-next": "npm:@opencode-ai/plugin@0.0.0-next-16281",
|
|
79
|
+
"@tsconfig/node22": "^22.0.5",
|
|
80
|
+
"@types/node": "^22.20.1",
|
|
81
|
+
"typescript": "^5.9.3"
|
|
82
82
|
}
|
|
83
83
|
}
|