cursor-opencode-provider 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -8
- package/dist/context/paths.d.ts +21 -4
- package/dist/context/paths.js +47 -5
- package/dist/language-model.d.ts +1 -1
- package/dist/language-model.js +91 -11
- package/dist/protocol/exec-variants.js +2 -2
- package/dist/protocol/messages.js +89 -2
- package/dist/protocol/tools.d.ts +43 -0
- package/dist/protocol/tools.js +244 -4
- package/dist/session.d.ts +17 -2
- package/dist/usage.d.ts +12 -3
- package/dist/usage.js +20 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
# cursor-opencode-provider
|
|
2
2
|
|
|
3
|
-
Use [Cursor](https://cursor.com) subscription models from [OpenCode](https://opencode.ai) by speaking Cursor's Connect-RPC agent protocol.
|
|
3
|
+
Use [Cursor](https://cursor.com) subscription models from [OpenCode](https://opencode.ai) and compatible coding agents by speaking Cursor's Connect-RPC agent protocol.
|
|
4
4
|
|
|
5
5
|
This project is a custom **AI SDK provider** (`LanguageModelV3`) plus an **OpenCode plugin** that handles authentication and model discovery. Instead of calling a generic chat-completions API, it encodes and decodes Cursor's protobuf agent protocol over HTTP/2 to Cursor's agent backend.
|
|
6
6
|
|
|
7
|
-
> **
|
|
7
|
+
> **Runs unchanged across OpenCode, OpenCode 2.0, and four additional coding agents.** OpenCode loads this plugin natively, including through the dedicated OpenCode 2.0 entrypoint. [OCP — OpenCode Plugin Compatibility](https://github.com/oakimov/opencode-plugin-compat) runs the same published plugin, without a provider fork, in **Kilo Code, MiMo Code, pi, and oh-my-pi**. See [Coding-agent compatibility](#coding-agent-compatibility).
|
|
8
|
+
>
|
|
9
|
+
> **Status:** Usable end-to-end for authentication, model discovery, streaming, and tools. See [Known limitations](#known-limitations).
|
|
10
|
+
|
|
11
|
+
## Coding-agent compatibility
|
|
12
|
+
|
|
13
|
+
This package remains one OpenCode plugin and AI SDK provider. The external
|
|
14
|
+
**[OCP compatibility layer](https://github.com/oakimov/opencode-plugin-compat)**
|
|
15
|
+
adapts other hosts around that unchanged package: it translates their plugin
|
|
16
|
+
surfaces, model catalogs, tool vocabulary, authentication, and streaming events
|
|
17
|
+
instead of requiring a host-specific Cursor provider.
|
|
18
|
+
|
|
19
|
+
| Coding agent | How this package runs |
|
|
20
|
+
|---|---|
|
|
21
|
+
| **OpenCode** | Native plugin and AI SDK provider |
|
|
22
|
+
| **OpenCode 2.0** | Native dedicated `cursor-opencode-provider/plugin/opencode2` entrypoint |
|
|
23
|
+
| **Kilo Code** | Unchanged through OCP's OpenCode-clone compatibility layer |
|
|
24
|
+
| **MiMo Code** | Unchanged through OCP's OpenCode-clone compatibility layer |
|
|
25
|
+
| **pi** | Unchanged through `@opencode-compat/pi-bridge` and pi's provider extension API |
|
|
26
|
+
| **oh-my-pi (omp)** | Unchanged through the same Pi-family bridge |
|
|
27
|
+
|
|
28
|
+
Start with OCP's host guides:
|
|
29
|
+
|
|
30
|
+
- [OpenCode clones: Kilo Code and MiMo Code](https://github.com/oakimov/opencode-plugin-compat/blob/main/docs/hosts/opencode-clones.md)
|
|
31
|
+
- [Pi family: pi and oh-my-pi](https://github.com/oakimov/opencode-plugin-compat/blob/main/docs/hosts/pi-family.md)
|
|
32
|
+
|
|
33
|
+
OCP is designed to grow by adding host profiles and narrow adapters or bridges,
|
|
34
|
+
so support for other coding agents can be added there while this provider and
|
|
35
|
+
its package remain unchanged.
|
|
8
36
|
|
|
9
37
|
## Demo
|
|
10
38
|
|
|
@@ -14,6 +42,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
14
42
|
|
|
15
43
|
## Features
|
|
16
44
|
|
|
45
|
+
- **Multi-host compatibility** — the same plugin package runs natively in OpenCode and OpenCode 2.0, and unchanged through OCP in Kilo Code, MiMo Code, pi, and oh-my-pi; new host support belongs in the compatibility layer rather than a provider fork
|
|
17
46
|
- **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
|
|
18
47
|
- **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
|
|
19
48
|
- **Model discovery** — fetches available models from Cursor's API and caches them locally
|
|
@@ -25,7 +54,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
25
54
|
## Requirements
|
|
26
55
|
|
|
27
56
|
- [Bun](https://bun.sh) (for development and tests)
|
|
28
|
-
- [OpenCode](https://opencode.ai)
|
|
57
|
+
- [OpenCode](https://opencode.ai), or a supported host configured through [OCP](https://github.com/oakimov/opencode-plugin-compat)
|
|
29
58
|
- An active Cursor account with API access
|
|
30
59
|
|
|
31
60
|
## Installation
|
|
@@ -48,7 +77,7 @@ Add the package to OpenCode config. OpenCode installs npm plugins with Bun at st
|
|
|
48
77
|
}
|
|
49
78
|
```
|
|
50
79
|
|
|
51
|
-
Pin a version if you want: `"cursor-opencode-provider@0.4.
|
|
80
|
+
Pin a version if you want: `"cursor-opencode-provider@0.4.1"`.
|
|
52
81
|
|
|
53
82
|
You can also install it yourself first:
|
|
54
83
|
|
|
@@ -118,7 +147,7 @@ browser login or an API key. `CURSOR_API_KEY` is also picked up automatically.
|
|
|
118
147
|
}
|
|
119
148
|
```
|
|
120
149
|
|
|
121
|
-
Pin a version if you want: `"cursor-opencode-provider@0.4.
|
|
150
|
+
Pin a version if you want: `"cursor-opencode-provider@0.4.1/plugin/opencode2"`.
|
|
122
151
|
|
|
123
152
|
OpenCode 2.0 installs the published package into its host cache and loads the AI SDK
|
|
124
153
|
entry from there (`aisdk:cursor-opencode-provider`). No extra env vars are required.
|
|
@@ -379,11 +408,17 @@ The provider adds OpenCode-specific system guidance to normal tool-capable conve
|
|
|
379
408
|
|
|
380
409
|
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).
|
|
381
410
|
|
|
411
|
+
### Cursor edit handshakes
|
|
412
|
+
|
|
413
|
+
Cursor's legacy `edit_tool_call` is implemented internally as a correlated `read_args` followed by a whole-file `write_args`; the write is transport mechanics, not a new decision to replace OpenCode's `edit` tool. The provider retains that correlation and exposes an existing-file mutation as one unique, line-bounded OpenCode `edit`. It still returns Cursor's expected typed `write_result`. New-file creation remains a `write`, and an `apply_patch`-only catalog receives the same targeted change as an `*** Update File:` patch.
|
|
414
|
+
|
|
415
|
+
The prerequisite native read also preserves an existing final LF/CRLF. OpenCode's numbered read envelope cannot represent that terminator, and dropping it made Cursor's exact edit matcher reject valid replacements, retry reads, and eventually rewrite the file. This restoration applies only to confirmed-complete, unbounded reads; paged and capped reads keep their existing truncation behavior.
|
|
416
|
+
|
|
382
417
|
### `apply_patch` models (GPT-5 and friends)
|
|
383
418
|
|
|
384
419
|
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.
|
|
385
420
|
|
|
386
|
-
The provider translates transparently:
|
|
421
|
+
The provider translates transparently: an uncorrelated native write becomes an `*** Add File:` patch, while a correlated legacy edit or 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.
|
|
387
422
|
|
|
388
423
|
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.
|
|
389
424
|
|
|
@@ -425,7 +460,8 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
|
|
|
425
460
|
| Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
|
|
426
461
|
| `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
|
|
427
462
|
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
|
|
428
|
-
|
|
|
463
|
+
| OpenCode compacts immediately after a tool-heavy task | Upgrade to a version containing the cumulative-usage fix. Cursor's final `TurnEnded` counters cover the entire held multi-step Run; older provider versions exposed that aggregate as the final request's context size, which made OpenCode compact far too early. Fixed versions keep the raw counters in `providerMetadata.cursor` and report a request-local context estimate instead. |
|
|
464
|
+
| Cache **write** tokens always show as `0` | Cursor's `TurnEnded` protobuf includes `cache_write` (field 4), but captured Runs set it to `0` even when `cache_read` is large. Single-step Runs expose those counters directly. For multi-step Runs they remain in `providerMetadata.cursor` because Cursor reports only a cumulative whole-Run total, which cannot safely be used as one OpenCode request's context usage. With debug logging, confirm `turn_ended raw wire fields: … f4:wt0=0`. |
|
|
429
465
|
|
|
430
466
|
## Security
|
|
431
467
|
|
|
@@ -434,12 +470,13 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
434
470
|
## Known limitations
|
|
435
471
|
|
|
436
472
|
- **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
|
|
437
|
-
- **`TurnEnded
|
|
473
|
+
- **`TurnEnded` usage is whole-Run usage** — on a held multi-step Cursor Run, the final `interaction_update.turn_ended` counters aggregate every model invocation across tool boundaries. OpenCode usage is request-local and uses the latest assistant message to decide compaction, so publishing the aggregate there both double-counts earlier steps and causes premature compaction. The provider therefore uses a current-context estimate for multi-step AI SDK usage and preserves the exact cumulative counters in `providerMetadata.cursor`; single-step Runs still map the counters directly. Cursor also consistently sends `cache_write=0` in captured Runs, so no alternate write count is available.
|
|
438
474
|
- **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. `env.workspace_paths` / `process_working_directory` stay on the real git workspace; Cursor's metadata root (`project_folder`, MCP `workspace_project_dir`, terminals/transcripts) is advertised under `<host-cache>/projects/<slug>/` (default `~/.cache/opencode/projects/…`) so dumps like `agent-tools/` do not land in the repo. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
|
|
439
475
|
- **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
|
|
440
476
|
- **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name; known-but-unsupported and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
|
|
441
477
|
- **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
|
|
442
478
|
- **Host web tools use collision-safe aliases** — Cursor sees `custom_websearch` / `custom_webfetch`, and the held Run maps each alias back to an executable OpenCode tool with its schema, permission check, and correlated result intact. The plugin registers its search fallback directly as `custom_websearch`, avoiding OpenCode's reserved `websearch` id filter for third-party providers and taking precedence over MCP search providers such as Brave without host environment configuration. Exact host `webfetch` is translated the same way. Cursor's UI-bound native web interactions stay disabled because their approval replies cannot carry OpenCode tool results.
|
|
479
|
+
- **MCP resource tools (`list_mcp_resources` / `read_mcp_resource`) also use collision-safe aliases, and Cursor's native resource exec is always answered** — OpenCode's resource tools (advertised only when a connected MCP server declares `capabilities.resources`) share bare names with two of Cursor's own native tool-call variants. Left unaliased, Cursor's client can route a call onto its native exec channel instead of the ordinary MCP tool path, which this provider does not translate — confirmed live: reading a resource was dispatched natively and killed the Run before this fix. The provider now aliases both names to `custom_list_mcp_resources` / `custom_read_mcp_resource` (same mechanism as web search/fetch) so normal calls execute through OpenCode's tools, and separately answers Cursor's native resource-exec fields directly on the protocol floor — an empty list, or a "server not found" error for read — so an unsolicited native request from Cursor can never fail the Run. Downloads (`download_path`) have no OpenCode equivalent and are always refused; binary resource content is subject to the same [50 KB / attachment-type limits](#known-limitations) as other tool results.
|
|
443
480
|
- **Background shells are non-interactive** — Cursor's native background-shell spawn and soft-background shell-stream timeouts are bridged through OpenCode's foreground-only `bash` tool. With bash/zsh, the classic plugin keeps the original permission/UI command and executes the wrapper through `shell.env` (`BASH_ENV` / `ZDOTDIR`); OpenCode's non-interactive sh/dash argv ignores those startup variables, so the plugin uses a short `exec /bin/sh '<wrapper-file>'` command backed by the same private wrapper. Native background-spawn requests also carry a self-contained marker-producing fallback when the classic hooks are absent. Spawn/soft-bg wrappers detach with `nohup`, redirect output under `${TMPDIR:-/tmp}/cursor-opencode-{bg,shell}.*`, and return the real PID (or typed timeout/exit) to Cursor. Private markers and OpenCode's `<shell_metadata>` envelope are stripped before storage/render, with a short still-running / started / timed-out status line left for the bash bubble. Requests that require `write_shell_stdin` are rejected explicitly because OpenCode does not expose an interactive background-process lifecycle through its AI SDK tool interface. The POSIX wrap path is not implemented for native Windows PowerShell/`cmd`.
|
|
444
481
|
- **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and available web capabilities to executable host tools (`question`, `todowrite`, `plan_enter` / `plan_exit`, `custom_websearch`, `custom_webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind host tool permissions. Separately from display `create_plan_tool_call` → `todowrite` mirroring, interaction `create_plan_request_query` is auto-acked (CLI headless parity) with success and an empty `plan_uri`, so Cursor may treat the plan as accepted without an OpenCode UI confirm. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
|
|
445
482
|
- **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.
|
package/dist/context/paths.d.ts
CHANGED
|
@@ -26,15 +26,32 @@ type CompatDetector = () => CompatDetectResult;
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function setHostCacheDirOverride(dir: string | undefined): void;
|
|
28
28
|
export declare function getHostCacheDirOverride(): string | undefined;
|
|
29
|
+
declare const HOST_CACHE_DIRS: readonly ["mimocode", "kilo", "opencode"];
|
|
30
|
+
export type HostCacheDir = (typeof HOST_CACHE_DIRS)[number];
|
|
31
|
+
/**
|
|
32
|
+
* Host cache-dir name inferred from the running binary (`argv[0]` /
|
|
33
|
+
* `process.execPath`). A source-checkout provider can't be located by install
|
|
34
|
+
* path, but the binary that loaded it is authoritative: when this process IS
|
|
35
|
+
* `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
|
|
36
|
+
* OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
|
|
37
|
+
* (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
|
|
38
|
+
* not binary identity, and could otherwise cause false positives (e.g. an
|
|
39
|
+
* `--opencode-config=…` flag under a fork).
|
|
40
|
+
*/
|
|
41
|
+
export declare function hostCacheDirFromProcess(argv?: readonly string[], execPath?: string): HostCacheDir | undefined;
|
|
29
42
|
/**
|
|
30
43
|
* Resolve the host cache directory without an override.
|
|
31
44
|
*
|
|
32
45
|
* Explicit host environment wins. Otherwise, an installed provider inherits
|
|
33
|
-
* the host-named cache containing its module
|
|
34
|
-
*
|
|
35
|
-
* config directory installed is not evidence that it
|
|
46
|
+
* the host-named cache containing its module; a source checkout is attributed
|
|
47
|
+
* by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
|
|
48
|
+
* having another host's config directory installed is not evidence that it
|
|
49
|
+
* owns this process.
|
|
36
50
|
*/
|
|
37
|
-
export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string
|
|
51
|
+
export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string, processIdentity?: {
|
|
52
|
+
argv?: readonly string[];
|
|
53
|
+
execPath?: string;
|
|
54
|
+
}): string;
|
|
38
55
|
/**
|
|
39
56
|
* Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
|
|
40
57
|
* when the host is supported. No-op when OCP is absent or detection fails.
|
package/dist/context/paths.js
CHANGED
|
@@ -48,15 +48,52 @@ export function setHostCacheDirOverride(dir) {
|
|
|
48
48
|
export function getHostCacheDirOverride() {
|
|
49
49
|
return hostCacheDirOverride;
|
|
50
50
|
}
|
|
51
|
+
const HOST_CACHE_DIRS = ["mimocode", "kilo", "opencode"];
|
|
52
|
+
/**
|
|
53
|
+
* Host cache-dir name inferred from the running binary (`argv[0]` /
|
|
54
|
+
* `process.execPath`). A source-checkout provider can't be located by install
|
|
55
|
+
* path, but the binary that loaded it is authoritative: when this process IS
|
|
56
|
+
* `mimo` / `kilocode`, the cache root is that host's, not OpenCode's. Mirrors
|
|
57
|
+
* OCP `detect()` binaryHint semantics and tolerates the leading-dot Kilo binary
|
|
58
|
+
* (`.kilo`). Only `argv[0]` is inspected — later argv entries are arguments,
|
|
59
|
+
* not binary identity, and could otherwise cause false positives (e.g. an
|
|
60
|
+
* `--opencode-config=…` flag under a fork).
|
|
61
|
+
*/
|
|
62
|
+
export function hostCacheDirFromProcess(argv = process.argv, execPath = process.execPath) {
|
|
63
|
+
for (const raw of [argv[0], execPath]) {
|
|
64
|
+
if (!raw)
|
|
65
|
+
continue;
|
|
66
|
+
const name = path.basename(String(raw)).toLowerCase().replace(/^\.+/, "");
|
|
67
|
+
if (name === "mimo" ||
|
|
68
|
+
name === "mimocode" ||
|
|
69
|
+
name.startsWith("mimo-") ||
|
|
70
|
+
name.includes("mimocode")) {
|
|
71
|
+
return "mimocode";
|
|
72
|
+
}
|
|
73
|
+
if (name === "kilo" ||
|
|
74
|
+
name === "kilocode" ||
|
|
75
|
+
name.startsWith("kilo-") ||
|
|
76
|
+
name.includes("kilocode")) {
|
|
77
|
+
return "kilo";
|
|
78
|
+
}
|
|
79
|
+
if (name === "opencode" ||
|
|
80
|
+
name.startsWith("opencode-") ||
|
|
81
|
+
name.includes("opencode")) {
|
|
82
|
+
return "opencode";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
51
87
|
/**
|
|
52
88
|
* Resolve the host cache directory without an override.
|
|
53
89
|
*
|
|
54
90
|
* Explicit host environment wins. Otherwise, an installed provider inherits
|
|
55
|
-
* the host-named cache containing its module
|
|
56
|
-
*
|
|
57
|
-
* config directory installed is not evidence that it
|
|
91
|
+
* the host-named cache containing its module; a source checkout is attributed
|
|
92
|
+
* by the running binary identity (see {@link hostCacheDirFromProcess}). Merely
|
|
93
|
+
* having another host's config directory installed is not evidence that it
|
|
94
|
+
* owns this process.
|
|
58
95
|
*/
|
|
59
|
-
export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url) {
|
|
96
|
+
export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url, processIdentity = {}) {
|
|
60
97
|
const mimoHome = env.MIMOCODE_HOME;
|
|
61
98
|
if (mimoHome && mimoHome.length > 0) {
|
|
62
99
|
return path.join(mimoHome, "cache");
|
|
@@ -74,12 +111,17 @@ export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.u
|
|
|
74
111
|
modulePath = undefined;
|
|
75
112
|
}
|
|
76
113
|
if (modulePath) {
|
|
77
|
-
for (const host of
|
|
114
|
+
for (const host of HOST_CACHE_DIRS) {
|
|
78
115
|
const root = path.resolve(cacheHome, host);
|
|
79
116
|
if (modulePath === root || modulePath.startsWith(`${root}${path.sep}`))
|
|
80
117
|
return root;
|
|
81
118
|
}
|
|
82
119
|
}
|
|
120
|
+
// Source checkout: the module path can't name the host, but the binary that
|
|
121
|
+
// loaded us can (running as `mimo` / `kilocode` / `opencode`).
|
|
122
|
+
const host = hostCacheDirFromProcess(processIdentity.argv ?? process.argv, processIdentity.execPath ?? process.execPath);
|
|
123
|
+
if (host)
|
|
124
|
+
return path.join(cacheHome, host);
|
|
83
125
|
return path.join(cacheHome, "opencode");
|
|
84
126
|
}
|
|
85
127
|
/**
|
package/dist/language-model.d.ts
CHANGED
|
@@ -85,7 +85,7 @@ export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[
|
|
|
85
85
|
export declare function estimateTokens(chars: number): number;
|
|
86
86
|
/** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
|
|
87
87
|
export declare function estimatePromptTokens(prompt: LanguageModelV3CallOptions["prompt"]): number;
|
|
88
|
-
/** Preserve
|
|
88
|
+
/** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
|
|
89
89
|
export declare function cursorTurnEndedProviderMetadata(te: Record<string, unknown>): {
|
|
90
90
|
cursor: {
|
|
91
91
|
usageVersion: number;
|
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, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_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";
|
|
@@ -464,13 +464,19 @@ export async function pumpWithRecovery(input) {
|
|
|
464
464
|
const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
|
|
465
465
|
trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
|
|
466
466
|
await sleepForRetry(delayMs, input.abortSignal);
|
|
467
|
-
|
|
467
|
+
const recovery = checkpoint
|
|
468
468
|
? {
|
|
469
469
|
kind: "resume",
|
|
470
470
|
conversationId: pumpedSession.conversationId,
|
|
471
471
|
checkpoint: Uint8Array.from(checkpoint),
|
|
472
472
|
}
|
|
473
|
-
: { kind: "rebase" }
|
|
473
|
+
: { kind: "rebase" };
|
|
474
|
+
session = await input.recover(recovery);
|
|
475
|
+
if (recovery.kind === "resume") {
|
|
476
|
+
session.hadToolCallBoundary = pumpedSession.hadToolCallBoundary;
|
|
477
|
+
session.usageEstimate = { ...pumpedSession.usageEstimate };
|
|
478
|
+
session.editToolCalls = new Map(pumpedSession.editToolCalls);
|
|
479
|
+
}
|
|
474
480
|
input.onSession?.(session);
|
|
475
481
|
}
|
|
476
482
|
finally {
|
|
@@ -693,6 +699,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
693
699
|
frames: stream.frames()[Symbol.asyncIterator](),
|
|
694
700
|
pending: new Map(),
|
|
695
701
|
displayToolCalls: new Map(),
|
|
702
|
+
editToolCalls: new Map(),
|
|
696
703
|
nextBridgedExecId: 900_000,
|
|
697
704
|
blobs: new Map(),
|
|
698
705
|
toolDescriptors,
|
|
@@ -1082,6 +1089,14 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1082
1089
|
const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
1083
1090
|
if (!requested)
|
|
1084
1091
|
return false;
|
|
1092
|
+
// A scheme-addressed target is the host's to resolve, not a local file to
|
|
1093
|
+
// stat. Rejecting it here makes host-mounted capabilities (oh-my-pi's
|
|
1094
|
+
// `xd://` device URLs, which is how every MCP tool is exposed by default)
|
|
1095
|
+
// permanently unreachable through this provider.
|
|
1096
|
+
if (isUriReadTarget(requested)) {
|
|
1097
|
+
trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1085
1100
|
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
1086
1101
|
const absolutePath = resolveReadTargetPath(requested, workspaceRoot);
|
|
1087
1102
|
const readResult = classifyMissingReadTarget(absolutePath);
|
|
@@ -1145,8 +1160,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1145
1160
|
};
|
|
1146
1161
|
const emitFinish = (te, reason) => {
|
|
1147
1162
|
closeOpenSpans();
|
|
1148
|
-
|
|
1149
|
-
|
|
1163
|
+
// TurnEnded is request-local only when this Run did not previously stop at
|
|
1164
|
+
// a tool boundary. On a held multi-step Run Cursor aggregates every model
|
|
1165
|
+
// invocation into these counters; treating that billed total as the final
|
|
1166
|
+
// request's prompt size makes OpenCode compact after ordinary tasks.
|
|
1167
|
+
const cumulativeTurnEnded = !!te && session.hadToolCallBoundary === true;
|
|
1168
|
+
if (te && !cumulativeTurnEnded) {
|
|
1150
1169
|
session.usageEstimate = {
|
|
1151
1170
|
inputTokens: turnEndedCounter(te, "input_tokens"),
|
|
1152
1171
|
outputTokens: turnEndedCounter(te, "output_tokens"),
|
|
@@ -1156,9 +1175,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1156
1175
|
};
|
|
1157
1176
|
}
|
|
1158
1177
|
const est = session.usageEstimate;
|
|
1159
|
-
const usage = te
|
|
1178
|
+
const usage = te && !cumulativeTurnEnded
|
|
1160
1179
|
? buildLanguageModelV3UsageFromTurnEnded(te)
|
|
1161
1180
|
: buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
|
|
1181
|
+
if (!te)
|
|
1182
|
+
session.hadToolCallBoundary = true;
|
|
1162
1183
|
const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
|
|
1163
1184
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
1164
1185
|
? String(reason.unified ?? "unknown")
|
|
@@ -1169,9 +1190,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1169
1190
|
`v3In=${inTotal} v3Out=${outTotal} ` +
|
|
1170
1191
|
`v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
|
|
1171
1192
|
`v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
|
|
1172
|
-
`rawIn=${est.inputTokens}
|
|
1173
|
-
`
|
|
1174
|
-
`
|
|
1193
|
+
`rawIn=${te ? turnEndedCounter(te, "input_tokens") : est.inputTokens} ` +
|
|
1194
|
+
`rawOut=${te ? turnEndedCounter(te, "output_tokens") : est.outputTokens} ` +
|
|
1195
|
+
`rawCacheRead=${te ? turnEndedCounter(te, "cache_read") : est.cacheRead} ` +
|
|
1196
|
+
`rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
|
|
1197
|
+
`source=${te ? (cumulativeTurnEnded ? "estimate-cumulative-turn-ended" : "turn_ended") : "estimate"}`);
|
|
1175
1198
|
safeEnqueue({
|
|
1176
1199
|
type: "finish",
|
|
1177
1200
|
usage,
|
|
@@ -1327,6 +1350,15 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1327
1350
|
if (callId && toolCall) {
|
|
1328
1351
|
session.displayToolCalls.set(callId, toolCall);
|
|
1329
1352
|
const variant = Object.keys(toolCall).find((k) => k.endsWith("_tool_call")) ?? "?";
|
|
1353
|
+
const display = parseDisplayToolCall(callId, toolCall);
|
|
1354
|
+
const editPath = display?.variant === "edit_tool_call"
|
|
1355
|
+
&& typeof display.args.path === "string"
|
|
1356
|
+
? display.args.path
|
|
1357
|
+
: undefined;
|
|
1358
|
+
if (editPath) {
|
|
1359
|
+
const editToolCalls = session.editToolCalls ?? (session.editToolCalls = new Map());
|
|
1360
|
+
editToolCalls.set(callId, { path: editPath });
|
|
1361
|
+
}
|
|
1330
1362
|
const callIdLog = callId.replace(/\r?\n/g, "\\n");
|
|
1331
1363
|
let wireFields = "";
|
|
1332
1364
|
if (variant === "?") {
|
|
@@ -1341,6 +1373,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1341
1373
|
else if (iu?.tool_call_completed) {
|
|
1342
1374
|
const completed = iu.tool_call_completed;
|
|
1343
1375
|
const callId = typeof completed.call_id === "string" ? completed.call_id : "";
|
|
1376
|
+
if (callId)
|
|
1377
|
+
session.editToolCalls?.delete(callId);
|
|
1344
1378
|
// If exec already claimed this call_id, display map entry is gone — skip.
|
|
1345
1379
|
if (!callId || !session.displayToolCalls.has(callId)) {
|
|
1346
1380
|
if (callId) {
|
|
@@ -1434,8 +1468,36 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1434
1468
|
failRunProtocol("Cursor MCP-state reply failed", RUN_REPLY_FAILED);
|
|
1435
1469
|
}
|
|
1436
1470
|
}
|
|
1471
|
+
else if (esm.list_mcp_resources_exec_args) {
|
|
1472
|
+
// Native Cursor exec (agent.v1 field 17), no OpenCode route under
|
|
1473
|
+
// Option B — see tasks/plans/fix-cursor-mcp-resource-exec.md. Answer
|
|
1474
|
+
// with Cursor's own no-client-qualifies shape and keep pumping.
|
|
1475
|
+
const args = esm.list_mcp_resources_exec_args;
|
|
1476
|
+
const server = typeof args.server === "string" ? args.server : "";
|
|
1477
|
+
try {
|
|
1478
|
+
session.stream.write(buildListMcpResourcesFallback(esmId));
|
|
1479
|
+
trace(`exec list_mcp_resources: replied id=${esmId} server=${server || "(all)"} success{resources:[]}`);
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
failRunProtocol("Cursor list_mcp_resources reply failed", RUN_REPLY_FAILED);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
else if (esm.read_mcp_resource_exec_args) {
|
|
1486
|
+
// Native Cursor exec (agent.v1 field 18) — same rationale as above.
|
|
1487
|
+
const args = esm.read_mcp_resource_exec_args;
|
|
1488
|
+
const server = typeof args.server === "string" ? args.server : "";
|
|
1489
|
+
const uri = typeof args.uri === "string" ? args.uri : "";
|
|
1490
|
+
try {
|
|
1491
|
+
session.stream.write(buildReadMcpResourceFallback(esmId, server, uri));
|
|
1492
|
+
trace(`exec read_mcp_resource: replied id=${esmId} server=${server} uri=${uri} error{server not found}`);
|
|
1493
|
+
}
|
|
1494
|
+
catch {
|
|
1495
|
+
failRunProtocol("Cursor read_mcp_resource reply failed", RUN_REPLY_FAILED);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1437
1498
|
else {
|
|
1438
1499
|
replaySafety.markBarrier("non-control-exec");
|
|
1500
|
+
const displayCallId = extractExecDisplayCallId(esm);
|
|
1439
1501
|
const parsed = parseExecServerMessage(esm);
|
|
1440
1502
|
if (parsed) {
|
|
1441
1503
|
const executableToolName = resolveCustomWebToolAlias(parsed.toolName, session.toolAliases);
|
|
@@ -1444,12 +1506,24 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1444
1506
|
parsed.toolName = executableToolName;
|
|
1445
1507
|
}
|
|
1446
1508
|
remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
|
|
1509
|
+
const editCall = displayCallId
|
|
1510
|
+
? session.editToolCalls?.get(displayCallId)
|
|
1511
|
+
: undefined;
|
|
1512
|
+
if (editCall) {
|
|
1513
|
+
const remapped = remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNameSet, editCall.path, workspaceRootFromRequestContext(session.requestContext));
|
|
1514
|
+
if (parsed.resultField === "write_result" && displayCallId) {
|
|
1515
|
+
session.editToolCalls?.delete(displayCallId);
|
|
1516
|
+
}
|
|
1517
|
+
if (remapped) {
|
|
1518
|
+
trace(`exec: preserved edit intent callId=${displayCallId} ` +
|
|
1519
|
+
`path=${JSON.stringify(editCall.path)}`);
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1447
1522
|
// Hosts that advertise `apply_patch` in place of `edit`/`write` (see
|
|
1448
1523
|
// protocol/apply-patch.ts) still receive Cursor's native write/edit
|
|
1449
1524
|
// exec requests. Translate before the unavailable-tool check below.
|
|
1450
1525
|
remapEditToolsForCatalog(parsed, advertisedToolNameSet, workspaceRootFromRequestContext(session.requestContext));
|
|
1451
1526
|
}
|
|
1452
|
-
const displayCallId = extractExecDisplayCallId(esm);
|
|
1453
1527
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
1454
1528
|
if (parsed) {
|
|
1455
1529
|
if (parsed.localError) {
|
|
@@ -1720,6 +1794,12 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1720
1794
|
if (names.has(CUSTOM_WEBFETCH_TOOL)) {
|
|
1721
1795
|
instructions.push(`- To fetch a known URL, call \`${CUSTOM_WEBFETCH_TOOL}\`; do not use Cursor's native WebFetch interaction.`);
|
|
1722
1796
|
}
|
|
1797
|
+
if (names.has(CUSTOM_LIST_MCP_RESOURCES_TOOL)) {
|
|
1798
|
+
instructions.push(`- To list MCP resources, call \`${CUSTOM_LIST_MCP_RESOURCES_TOOL}\`; do not use Cursor's native resource-listing interaction.`);
|
|
1799
|
+
}
|
|
1800
|
+
if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
|
|
1801
|
+
instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
|
|
1802
|
+
}
|
|
1723
1803
|
if (names.has("task") || names.has("actor")) {
|
|
1724
1804
|
const target = names.has("actor") ? "`actor`" : "`task`";
|
|
1725
1805
|
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
@@ -1770,7 +1850,7 @@ export function estimatePromptTokens(prompt) {
|
|
|
1770
1850
|
.join("\n");
|
|
1771
1851
|
return estimateTokens(serializedContent.length);
|
|
1772
1852
|
}
|
|
1773
|
-
/** Preserve
|
|
1853
|
+
/** Preserve exact Cursor counters as diagnostics when they are cumulative across a multi-step Run. */
|
|
1774
1854
|
export function cursorTurnEndedProviderMetadata(te) {
|
|
1775
1855
|
return {
|
|
1776
1856
|
cursor: {
|
|
@@ -10,8 +10,8 @@ export const CURSOR_EXEC_VARIANTS = [
|
|
|
10
10
|
{ requestField: 11, requestName: "mcp_args", resultField: 11, resultName: "mcp_result", handling: "opencode-tool" },
|
|
11
11
|
{ requestField: 14, requestName: "shell_stream_args", resultField: 14, resultName: "shell_stream", handling: "opencode-tool" },
|
|
12
12
|
{ requestField: 16, requestName: "background_shell_spawn_args", resultField: 16, resultName: "background_shell_spawn_result", handling: "opencode-tool" },
|
|
13
|
-
{ requestField: 17, requestName: "list_mcp_resources_exec_args", resultField: 17, resultName: "list_mcp_resources_exec_result", handling: "
|
|
14
|
-
{ requestField: 18, requestName: "read_mcp_resource_exec_args", resultField: 18, resultName: "read_mcp_resource_exec_result", handling: "
|
|
13
|
+
{ requestField: 17, requestName: "list_mcp_resources_exec_args", resultField: 17, resultName: "list_mcp_resources_exec_result", handling: "provider-control" },
|
|
14
|
+
{ requestField: 18, requestName: "read_mcp_resource_exec_args", resultField: 18, resultName: "read_mcp_resource_exec_result", handling: "provider-control" },
|
|
15
15
|
{ requestField: 20, requestName: "fetch_args", resultField: 20, resultName: "fetch_result", handling: "unsupported" },
|
|
16
16
|
{ requestField: 21, requestName: "record_screen_args", resultField: 21, resultName: "record_screen_result", handling: "unsupported" },
|
|
17
17
|
{ requestField: 22, requestName: "computer_use_args", resultField: 22, resultName: "computer_use_result", handling: "unsupported" },
|
|
@@ -679,6 +679,87 @@ export function createMessageTypes() {
|
|
|
679
679
|
{ id: 4, name: "provider_identifier", type: "string" },
|
|
680
680
|
{ id: 5, name: "tool_name", type: "string" },
|
|
681
681
|
]);
|
|
682
|
+
// ── MCP resource exec (fields 17/18) ──
|
|
683
|
+
// agent.v1 ListMcpResourcesExecArgs/Result, ReadMcpResourceExecArgs/Result.
|
|
684
|
+
// Native Cursor exec variants: no descriptor, no advertisement, and Cursor
|
|
685
|
+
// may emit either unsolicited regardless of what this provider advertises
|
|
686
|
+
// (tasks/plans/fix-cursor-mcp-resource-exec.md, Correction 2). Under Option
|
|
687
|
+
// B, list_mcp_resources/read_mcp_resource execute through the ordinary
|
|
688
|
+
// field-11 MCP path via the alias rules below, so any 17/18 that still
|
|
689
|
+
// reaches this provider gets the total fallback built in tools.ts.
|
|
690
|
+
addType(root, "SmartModeApproval", [
|
|
691
|
+
{ id: 1, name: "request_id", type: "string" },
|
|
692
|
+
{ id: 2, name: "reason", type: "string" },
|
|
693
|
+
]);
|
|
694
|
+
addType(root, "OutputLocation", [
|
|
695
|
+
{ id: 1, name: "file_path", type: "string" },
|
|
696
|
+
{ id: 2, name: "size_bytes", type: "int64" },
|
|
697
|
+
{ id: 3, name: "line_count", type: "int64" },
|
|
698
|
+
]);
|
|
699
|
+
addType(root, "ListMcpResourcesExecArgs", [
|
|
700
|
+
{ id: 1, name: "server", type: "string" },
|
|
701
|
+
]);
|
|
702
|
+
{
|
|
703
|
+
const t = new protobuf.Type("ListMcpResourcesExecResult_McpResource");
|
|
704
|
+
t.add(new protobuf.Field("uri", 1, "string"));
|
|
705
|
+
t.add(new protobuf.Field("name", 2, "string"));
|
|
706
|
+
t.add(new protobuf.Field("description", 3, "string"));
|
|
707
|
+
t.add(new protobuf.Field("mime_type", 4, "string"));
|
|
708
|
+
t.add(new protobuf.Field("server", 5, "string"));
|
|
709
|
+
t.add(new protobuf.MapField("annotations", 6, "string", "string"));
|
|
710
|
+
root.add(t);
|
|
711
|
+
}
|
|
712
|
+
addType(root, "ListMcpResourcesSuccess", [
|
|
713
|
+
{ id: 1, name: "resources", type: "ListMcpResourcesExecResult_McpResource", repeated: true },
|
|
714
|
+
]);
|
|
715
|
+
addType(root, "ListMcpResourcesError", [{ id: 1, name: "error", type: "string" }]);
|
|
716
|
+
addType(root, "ListMcpResourcesRejected", [{ id: 1, name: "reason", type: "string" }]);
|
|
717
|
+
addType(root, "ListMcpResourcesExecResult", [
|
|
718
|
+
{ id: 1, name: "success", type: "ListMcpResourcesSuccess" },
|
|
719
|
+
{ id: 2, name: "error", type: "ListMcpResourcesError" },
|
|
720
|
+
{ id: 3, name: "rejected", type: "ListMcpResourcesRejected" },
|
|
721
|
+
], [{ name: "result", fields: ["success", "error", "rejected"] }]);
|
|
722
|
+
addType(root, "ReadMcpResourceExecArgs", [
|
|
723
|
+
{ id: 1, name: "server", type: "string" },
|
|
724
|
+
{ id: 2, name: "uri", type: "string" },
|
|
725
|
+
{ id: 3, name: "download_path", type: "string" },
|
|
726
|
+
{ id: 4, name: "tool_call_id", type: "string" },
|
|
727
|
+
{ id: 5, name: "smart_mode_approval", type: "SmartModeApproval" },
|
|
728
|
+
]);
|
|
729
|
+
{
|
|
730
|
+
// Interleaved field order matches agent.proto exactly: the text/blob oneof
|
|
731
|
+
// (5/6) sits between the shared metadata (1-4) and the trailing
|
|
732
|
+
// annotations/download/output fields (7-9).
|
|
733
|
+
const t = new protobuf.Type("ReadMcpResourceSuccess");
|
|
734
|
+
t.add(new protobuf.Field("uri", 1, "string"));
|
|
735
|
+
t.add(new protobuf.Field("name", 2, "string"));
|
|
736
|
+
t.add(new protobuf.Field("description", 3, "string"));
|
|
737
|
+
t.add(new protobuf.Field("mime_type", 4, "string"));
|
|
738
|
+
t.add(new protobuf.Field("text", 5, "string"));
|
|
739
|
+
t.add(new protobuf.Field("blob", 6, "bytes"));
|
|
740
|
+
t.add(new protobuf.MapField("annotations", 7, "string", "string"));
|
|
741
|
+
t.add(new protobuf.Field("download_path", 8, "string"));
|
|
742
|
+
t.add(new protobuf.Field("output_location", 9, "OutputLocation"));
|
|
743
|
+
t.add(new protobuf.OneOf("content", ["text", "blob"]));
|
|
744
|
+
root.add(t);
|
|
745
|
+
}
|
|
746
|
+
addType(root, "ReadMcpResourceError", [
|
|
747
|
+
{ id: 1, name: "uri", type: "string" },
|
|
748
|
+
{ id: 2, name: "error", type: "string" },
|
|
749
|
+
]);
|
|
750
|
+
// Cursor quirk: on `rejected` only `reason` is set — `uri` (field 1) is
|
|
751
|
+
// deliberately left empty. Mirror this; do not backfill uri on encode.
|
|
752
|
+
addType(root, "ReadMcpResourceRejected", [
|
|
753
|
+
{ id: 1, name: "uri", type: "string" },
|
|
754
|
+
{ id: 2, name: "reason", type: "string" },
|
|
755
|
+
]);
|
|
756
|
+
addType(root, "ReadMcpResourceNotFound", [{ id: 1, name: "uri", type: "string" }]);
|
|
757
|
+
addType(root, "ReadMcpResourceExecResult", [
|
|
758
|
+
{ id: 1, name: "success", type: "ReadMcpResourceSuccess" },
|
|
759
|
+
{ id: 2, name: "error", type: "ReadMcpResourceError" },
|
|
760
|
+
{ id: 3, name: "rejected", type: "ReadMcpResourceRejected" },
|
|
761
|
+
{ id: 4, name: "not_found", type: "ReadMcpResourceNotFound" },
|
|
762
|
+
], [{ name: "result", fields: ["success", "error", "rejected", "not_found"] }]);
|
|
682
763
|
// ── request_context (#10): server-initiated setup probe ──
|
|
683
764
|
// At the start of an agent turn the server sends ExecServerMessage
|
|
684
765
|
// {request_context_args} to ask the client for workspace/env/tool context.
|
|
@@ -841,6 +922,8 @@ export function createMessageTypes() {
|
|
|
841
922
|
{ id: 11, name: "mcp_args", type: "McpArgs" },
|
|
842
923
|
{ id: 14, name: "shell_stream_args", type: "ShellArgs" },
|
|
843
924
|
{ id: 16, name: "background_shell_spawn_args", type: "BackgroundShellSpawnArgs" },
|
|
925
|
+
{ id: 17, name: "list_mcp_resources_exec_args", type: "ListMcpResourcesExecArgs" },
|
|
926
|
+
{ id: 18, name: "read_mcp_resource_exec_args", type: "ReadMcpResourceExecArgs" },
|
|
844
927
|
{ id: 28, name: "subagent_args", type: "SubagentArgs" },
|
|
845
928
|
{ id: 36, name: "mcp_state_exec_args", type: "McpStateExecArgs" },
|
|
846
929
|
{ id: 45, name: "pi_read_args", type: "PiReadToolArgs" },
|
|
@@ -852,7 +935,8 @@ export function createMessageTypes() {
|
|
|
852
935
|
{ id: 51, name: "pi_ls_args", type: "PiLsToolArgs" },
|
|
853
936
|
], [{ name: "args", fields: [
|
|
854
937
|
"write_args", "delete_args", "grep_args", "read_args", "ls_args",
|
|
855
|
-
"request_context_args", "mcp_args", "shell_stream_args", "background_shell_spawn_args",
|
|
938
|
+
"request_context_args", "mcp_args", "shell_stream_args", "background_shell_spawn_args",
|
|
939
|
+
"list_mcp_resources_exec_args", "read_mcp_resource_exec_args", "mcp_state_exec_args",
|
|
856
940
|
"subagent_args",
|
|
857
941
|
"pi_read_args", "pi_bash_args", "pi_edit_args", "pi_write_args",
|
|
858
942
|
"pi_grep_args", "pi_find_args", "pi_ls_args",
|
|
@@ -871,6 +955,8 @@ export function createMessageTypes() {
|
|
|
871
955
|
{ id: 11, name: "mcp_result", type: "McpResult" },
|
|
872
956
|
{ id: 14, name: "shell_stream", type: "ShellStream" },
|
|
873
957
|
{ id: 16, name: "background_shell_spawn_result", type: "BackgroundShellSpawnResult" },
|
|
958
|
+
{ id: 17, name: "list_mcp_resources_exec_result", type: "ListMcpResourcesExecResult" },
|
|
959
|
+
{ id: 18, name: "read_mcp_resource_exec_result", type: "ReadMcpResourceExecResult" },
|
|
874
960
|
{ id: 28, name: "subagent_result", type: "SubagentResult" },
|
|
875
961
|
{ id: 36, name: "mcp_state_exec_result", type: "McpStateExecResult" },
|
|
876
962
|
{ id: 46, name: "pi_read_result", type: "PiReadExecResult" },
|
|
@@ -882,7 +968,8 @@ export function createMessageTypes() {
|
|
|
882
968
|
{ id: 52, name: "pi_ls_result", type: "PiLsExecResult" },
|
|
883
969
|
], [{ name: "result", fields: [
|
|
884
970
|
"write_result", "delete_result", "grep_result", "read_result", "ls_result",
|
|
885
|
-
"request_context_result", "mcp_result", "shell_stream", "background_shell_spawn_result",
|
|
971
|
+
"request_context_result", "mcp_result", "shell_stream", "background_shell_spawn_result",
|
|
972
|
+
"list_mcp_resources_exec_result", "read_mcp_resource_exec_result", "mcp_state_exec_result",
|
|
886
973
|
"subagent_result",
|
|
887
974
|
"pi_read_result", "pi_bash_result", "pi_edit_result", "pi_write_result",
|
|
888
975
|
"pi_grep_result", "pi_find_result", "pi_ls_result",
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type OpencodeToolDef = {
|
|
|
9
9
|
};
|
|
10
10
|
export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
|
|
11
11
|
export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
|
|
12
|
+
export declare const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
|
|
13
|
+
export declare const CUSTOM_READ_MCP_RESOURCE_TOOL = "custom_read_mcp_resource";
|
|
12
14
|
/** Cursor-facing alias → exact host tool name accepted by the AI SDK call. */
|
|
13
15
|
export type ToolAliasRegistry = ReadonlyMap<string, string>;
|
|
14
16
|
export type AliasedToolCatalog = {
|
|
@@ -87,6 +89,14 @@ export declare function resolveCursorSubagentType(subagentType: string, catalog?
|
|
|
87
89
|
export declare function mapCursorSubagentTypeToOpenCode(subagentType: string): string;
|
|
88
90
|
/** Resolve a native Cursor subagent request to this turn's executor and catalog. */
|
|
89
91
|
export declare function remapNativeSubagentForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, catalog?: HostSubagentCatalog): void;
|
|
92
|
+
/**
|
|
93
|
+
* Preserve a legacy Cursor edit's intent when its internal executor follows
|
|
94
|
+
* `edit_tool_call` with a whole-file `write_args` request.
|
|
95
|
+
*
|
|
96
|
+
* The result field intentionally remains `write_result`: that is the response
|
|
97
|
+
* Cursor is awaiting even though OpenCode executes the mutation via `edit`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function remapCorrelatedEditWriteForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, editPath: string, workspaceRoot?: string): boolean;
|
|
90
100
|
/**
|
|
91
101
|
* Express a native Cursor write/edit as `apply_patch` when the host swapped the
|
|
92
102
|
* edit-tool family out from under us.
|
|
@@ -131,11 +141,31 @@ export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<st
|
|
|
131
141
|
* → github_create_pull_request). Builtins under the default server stay bare.
|
|
132
142
|
*/
|
|
133
143
|
export declare function mcpRealToolName(mcpArgs: Record<string, unknown>, defaultServer?: string): string;
|
|
144
|
+
/**
|
|
145
|
+
* A read target addressed by URI scheme rather than by local filesystem path.
|
|
146
|
+
*
|
|
147
|
+
* Cursor's LocalReadExecutor only ever reads local files, so this provider
|
|
148
|
+
* mirrors its pre-execution validation. Host read tools are not so limited:
|
|
149
|
+
* oh-my-pi's `read` accepts "a path, URL, or internal URI", and mounts every
|
|
150
|
+
* discoverable tool — including all MCP server tools — under `xd://<tool>`
|
|
151
|
+
* device URLs that are read for their schema and written to for execution
|
|
152
|
+
* (`tools.xdev`, on by default). Resolving those against the workspace root
|
|
153
|
+
* mangles `xd://everything_echo` into `<root>/xd:/everything_echo`, which stats
|
|
154
|
+
* as missing and gets refused as `file_not_found` — so the host's read tool is
|
|
155
|
+
* never called and every xd://-mounted MCP tool is unreachable through this
|
|
156
|
+
* provider. Such targets must be forwarded untouched and left to the host.
|
|
157
|
+
*
|
|
158
|
+
* Requires a scheme of two or more characters followed by `://`, so Windows
|
|
159
|
+
* drive letters (`C:\src`, `C:/src`) stay local paths.
|
|
160
|
+
*/
|
|
161
|
+
export declare function isUriReadTarget(requested: string): boolean;
|
|
134
162
|
/**
|
|
135
163
|
* Resolve a read target the way Cursor's LocalReadExecutor does before it
|
|
136
164
|
* stats the file: untildify, then resolve a relative path against the workspace
|
|
137
165
|
* root (`env.workspace_paths[0]`), else resolve as-is. Kept in lockstep with
|
|
138
166
|
* agent utils `resolvePath(path, workspaceRoot)`.
|
|
167
|
+
*
|
|
168
|
+
* Only meaningful for local paths; check {@link isUriReadTarget} first.
|
|
139
169
|
*/
|
|
140
170
|
export declare function resolveReadTargetPath(requested: string, workspaceRoot: string): string;
|
|
141
171
|
/**
|
|
@@ -245,3 +275,16 @@ export declare function buildRequestContextResult(execId: number, requestContext
|
|
|
245
275
|
* provider's virtual MCP servers and their tools are available.
|
|
246
276
|
*/
|
|
247
277
|
export declare function buildMcpStateResult(execId: number, args: Record<string, unknown>, requestContext: Record<string, unknown>): Uint8Array;
|
|
278
|
+
/**
|
|
279
|
+
* Total fallback for Cursor's native MCP-resource exec channel (agent.v1
|
|
280
|
+
* fields #17/#18, tasks/plans/fix-cursor-mcp-resource-exec.md). Under Option B
|
|
281
|
+
* `list_mcp_resources`/`read_mcp_resource` execute through the ordinary
|
|
282
|
+
* field-11 MCP path via the alias rules above, so any 17/18 that still reaches
|
|
283
|
+
* this provider is Cursor emitting the native variant unsolicited (its own
|
|
284
|
+
* client owns both executors unconditionally — there is no descriptor to
|
|
285
|
+
* un-advertise). Cursor's executors are total; mirror that here rather than
|
|
286
|
+
* guessing a generic success, which risks an endless heartbeat loop.
|
|
287
|
+
*/
|
|
288
|
+
export declare function buildListMcpResourcesFallback(execId: number): Uint8Array;
|
|
289
|
+
/** See buildListMcpResourcesFallback. Always an `error`, even for `download_path`. */
|
|
290
|
+
export declare function buildReadMcpResourceFallback(execId: number, server: string, uri: string): Uint8Array;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -15,6 +15,8 @@ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-
|
|
|
15
15
|
export const REQUEST_CONTEXT_RESULT_FIELD = 10;
|
|
16
16
|
export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
|
|
17
17
|
export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
|
|
18
|
+
export const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
|
|
19
|
+
export const CUSTOM_READ_MCP_RESOURCE_TOOL = "custom_read_mcp_resource";
|
|
18
20
|
/** Match OpenCode's McpCatalog.sanitize for config server ids. */
|
|
19
21
|
export function sanitizeMcpServerId(value) {
|
|
20
22
|
return value.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
@@ -57,12 +59,13 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
|
|
|
57
59
|
export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
|
|
58
60
|
return tools.map((t) => {
|
|
59
61
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
60
|
-
const
|
|
62
|
+
const collisionSafeAlias = COLLISION_SAFE_ALIASES.has(t.name);
|
|
61
63
|
return {
|
|
62
64
|
// Keep the collision-safe public name exact. Prefixing it with the
|
|
63
65
|
// synthetic default server weakens the distinction from Cursor-native
|
|
64
|
-
// web
|
|
65
|
-
|
|
66
|
+
// capabilities (web search/fetch, MCP resource list/read) in the
|
|
67
|
+
// model-visible catalog.
|
|
68
|
+
name: collisionSafeAlias ? t.name : `${id.server}-${id.toolName}`,
|
|
66
69
|
description: t.description ?? "",
|
|
67
70
|
input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
|
|
68
71
|
provider_identifier: id.server,
|
|
@@ -76,6 +79,10 @@ function normalizeInputSchema(schema) {
|
|
|
76
79
|
}
|
|
77
80
|
return { type: "object", properties: {} };
|
|
78
81
|
}
|
|
82
|
+
// Despite the name, these rules cover any advertised OpenCode tool whose bare
|
|
83
|
+
// name collides with a Cursor-native capability, not only web search/fetch —
|
|
84
|
+
// see the MCP resource entries below and
|
|
85
|
+
// tasks/plans/fix-cursor-mcp-resource-exec.md Phase 0/2.
|
|
79
86
|
const WEB_ALIAS_RULES = [
|
|
80
87
|
{
|
|
81
88
|
alias: CUSTOM_WEBSEARCH_TOOL,
|
|
@@ -87,7 +94,23 @@ const WEB_ALIAS_RULES = [
|
|
|
87
94
|
exact: ["webfetch", "web_fetch"],
|
|
88
95
|
suffixes: ["_web_fetch", "-web_fetch", "_webfetch", "-webfetch"],
|
|
89
96
|
},
|
|
97
|
+
{
|
|
98
|
+
alias: CUSTOM_LIST_MCP_RESOURCES_TOOL,
|
|
99
|
+
exact: ["list_mcp_resources"],
|
|
100
|
+
suffixes: ["_list_mcp_resources", "-list_mcp_resources"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
alias: CUSTOM_READ_MCP_RESOURCE_TOOL,
|
|
104
|
+
exact: ["read_mcp_resource"],
|
|
105
|
+
suffixes: ["_read_mcp_resource", "-read_mcp_resource"],
|
|
106
|
+
},
|
|
90
107
|
];
|
|
108
|
+
const COLLISION_SAFE_ALIASES = new Set([
|
|
109
|
+
CUSTOM_WEBSEARCH_TOOL,
|
|
110
|
+
CUSTOM_WEBFETCH_TOOL,
|
|
111
|
+
CUSTOM_LIST_MCP_RESOURCES_TOOL,
|
|
112
|
+
CUSTOM_READ_MCP_RESOURCE_TOOL,
|
|
113
|
+
]);
|
|
91
114
|
/**
|
|
92
115
|
* Give collision-prone web capabilities names Cursor will not confuse with its
|
|
93
116
|
* native UI-bound WebSearch/WebFetch interactions. Exact host tools win; a
|
|
@@ -483,6 +506,125 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
483
506
|
}
|
|
484
507
|
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
485
508
|
const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
509
|
+
/** Return the number of non-overlapping occurrences, stopping after ambiguity. */
|
|
510
|
+
function replacementOccurrenceCount(source, needle) {
|
|
511
|
+
if (!needle)
|
|
512
|
+
return 0;
|
|
513
|
+
const first = source.indexOf(needle);
|
|
514
|
+
if (first === -1)
|
|
515
|
+
return 0;
|
|
516
|
+
return source.indexOf(needle, first + needle.length) === -1 ? 1 : 2;
|
|
517
|
+
}
|
|
518
|
+
function previousLineStart(source, start) {
|
|
519
|
+
if (start <= 0)
|
|
520
|
+
return 0;
|
|
521
|
+
const before = source[start - 1] === "\n" ? start - 2 : start - 1;
|
|
522
|
+
return source.lastIndexOf("\n", before) + 1;
|
|
523
|
+
}
|
|
524
|
+
function followingLineEnd(source, end) {
|
|
525
|
+
if (end >= source.length)
|
|
526
|
+
return source.length;
|
|
527
|
+
const newline = source.indexOf("\n", end);
|
|
528
|
+
return newline === -1 ? source.length : newline + 1;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Collapse a whole-file replacement into one unique, line-bounded substring
|
|
532
|
+
* edit. Cursor's legacy edit executor computes a complete new file internally;
|
|
533
|
+
* OpenCode's `edit` tool instead wants old/new substrings.
|
|
534
|
+
*/
|
|
535
|
+
function planWholeFileEdit(source, target) {
|
|
536
|
+
if (!source || source === target)
|
|
537
|
+
return undefined;
|
|
538
|
+
let prefix = 0;
|
|
539
|
+
const shared = Math.min(source.length, target.length);
|
|
540
|
+
while (prefix < shared && source[prefix] === target[prefix])
|
|
541
|
+
prefix++;
|
|
542
|
+
let suffix = 0;
|
|
543
|
+
while (suffix < source.length - prefix
|
|
544
|
+
&& suffix < target.length - prefix
|
|
545
|
+
&& source[source.length - suffix - 1] === target[target.length - suffix - 1])
|
|
546
|
+
suffix++;
|
|
547
|
+
const sourceChangeEnd = source.length - suffix;
|
|
548
|
+
const targetChangeEnd = target.length - suffix;
|
|
549
|
+
let start = prefix === 0 ? 0 : source.lastIndexOf("\n", prefix - 1) + 1;
|
|
550
|
+
let end = sourceChangeEnd >= source.length
|
|
551
|
+
? source.length
|
|
552
|
+
: followingLineEnd(source, sourceChangeEnd);
|
|
553
|
+
// A pure insertion can initially select no source text. Include an adjacent
|
|
554
|
+
// line because OpenCode edit deliberately rejects an empty oldString.
|
|
555
|
+
if (start === end) {
|
|
556
|
+
if (start > 0)
|
|
557
|
+
start = previousLineStart(source, start);
|
|
558
|
+
else
|
|
559
|
+
end = followingLineEnd(source, end);
|
|
560
|
+
}
|
|
561
|
+
const replacement = () => ({
|
|
562
|
+
oldString: source.slice(start, end),
|
|
563
|
+
newString: source.slice(start, prefix)
|
|
564
|
+
+ target.slice(prefix, targetChangeEnd)
|
|
565
|
+
+ source.slice(sourceChangeEnd, end),
|
|
566
|
+
});
|
|
567
|
+
let result = replacement();
|
|
568
|
+
while (replacementOccurrenceCount(source, result.oldString) !== 1) {
|
|
569
|
+
const priorStart = start;
|
|
570
|
+
const priorEnd = end;
|
|
571
|
+
if (start > 0)
|
|
572
|
+
start = previousLineStart(source, start);
|
|
573
|
+
if (end < source.length)
|
|
574
|
+
end = followingLineEnd(source, end);
|
|
575
|
+
if (start === priorStart && end === priorEnd)
|
|
576
|
+
return undefined;
|
|
577
|
+
result = replacement();
|
|
578
|
+
}
|
|
579
|
+
return result;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Preserve a legacy Cursor edit's intent when its internal executor follows
|
|
583
|
+
* `edit_tool_call` with a whole-file `write_args` request.
|
|
584
|
+
*
|
|
585
|
+
* The result field intentionally remains `write_result`: that is the response
|
|
586
|
+
* Cursor is awaiting even though OpenCode executes the mutation via `edit`.
|
|
587
|
+
*/
|
|
588
|
+
export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames, editPath, workspaceRoot) {
|
|
589
|
+
if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
|
|
590
|
+
return false;
|
|
591
|
+
const advertised = new Set(advertisedToolNames);
|
|
592
|
+
if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
|
|
593
|
+
return false;
|
|
594
|
+
const filePath = str(parsed.args.filePath);
|
|
595
|
+
const content = stringValue(parsed.args.content);
|
|
596
|
+
if (!filePath || content === undefined || !editPath)
|
|
597
|
+
return false;
|
|
598
|
+
const root = workspaceRoot ?? process.cwd();
|
|
599
|
+
const absolute = path.resolve(root, filePath);
|
|
600
|
+
if (absolute !== path.resolve(root, editPath))
|
|
601
|
+
return false;
|
|
602
|
+
let source;
|
|
603
|
+
try {
|
|
604
|
+
const stat = fs.statSync(absolute);
|
|
605
|
+
if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
|
|
606
|
+
return false;
|
|
607
|
+
if (Buffer.byteLength(content, "utf8") > MAX_EDIT_SOURCE_BYTES)
|
|
608
|
+
return false;
|
|
609
|
+
source = fs.readFileSync(absolute, "utf8");
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Missing targets are creations and must stay writes. Other read failures
|
|
613
|
+
// also fall back to the original host permission path without guessing.
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
const replacement = planWholeFileEdit(source, content);
|
|
617
|
+
if (!replacement)
|
|
618
|
+
return false;
|
|
619
|
+
parsed.toolName = "edit";
|
|
620
|
+
parsed.args = {
|
|
621
|
+
filePath,
|
|
622
|
+
oldString: replacement.oldString,
|
|
623
|
+
newString: replacement.newString,
|
|
624
|
+
};
|
|
625
|
+
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
486
628
|
/**
|
|
487
629
|
* Express a native Cursor write/edit as `apply_patch` when the host swapped the
|
|
488
630
|
* edit-tool family out from under us.
|
|
@@ -981,11 +1123,33 @@ function findOneOfVariant(msg, candidates) {
|
|
|
981
1123
|
function untildify(input) {
|
|
982
1124
|
return input.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
983
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* A read target addressed by URI scheme rather than by local filesystem path.
|
|
1128
|
+
*
|
|
1129
|
+
* Cursor's LocalReadExecutor only ever reads local files, so this provider
|
|
1130
|
+
* mirrors its pre-execution validation. Host read tools are not so limited:
|
|
1131
|
+
* oh-my-pi's `read` accepts "a path, URL, or internal URI", and mounts every
|
|
1132
|
+
* discoverable tool — including all MCP server tools — under `xd://<tool>`
|
|
1133
|
+
* device URLs that are read for their schema and written to for execution
|
|
1134
|
+
* (`tools.xdev`, on by default). Resolving those against the workspace root
|
|
1135
|
+
* mangles `xd://everything_echo` into `<root>/xd:/everything_echo`, which stats
|
|
1136
|
+
* as missing and gets refused as `file_not_found` — so the host's read tool is
|
|
1137
|
+
* never called and every xd://-mounted MCP tool is unreachable through this
|
|
1138
|
+
* provider. Such targets must be forwarded untouched and left to the host.
|
|
1139
|
+
*
|
|
1140
|
+
* Requires a scheme of two or more characters followed by `://`, so Windows
|
|
1141
|
+
* drive letters (`C:\src`, `C:/src`) stay local paths.
|
|
1142
|
+
*/
|
|
1143
|
+
export function isUriReadTarget(requested) {
|
|
1144
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]+:\/\//.test(requested);
|
|
1145
|
+
}
|
|
984
1146
|
/**
|
|
985
1147
|
* Resolve a read target the way Cursor's LocalReadExecutor does before it
|
|
986
1148
|
* stats the file: untildify, then resolve a relative path against the workspace
|
|
987
1149
|
* root (`env.workspace_paths[0]`), else resolve as-is. Kept in lockstep with
|
|
988
1150
|
* agent utils `resolvePath(path, workspaceRoot)`.
|
|
1151
|
+
*
|
|
1152
|
+
* Only meaningful for local paths; check {@link isUriReadTarget} first.
|
|
989
1153
|
*/
|
|
990
1154
|
export function resolveReadTargetPath(requested, workspaceRoot) {
|
|
991
1155
|
const expanded = untildify(requested);
|
|
@@ -1202,9 +1366,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1202
1366
|
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1203
1367
|
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1204
1368
|
// native channel, so this is the path that matters in practice.
|
|
1205
|
-
const content = unwrapReadOutput(output);
|
|
1206
1369
|
const statPath = extractPathTag(output) ?? readPath;
|
|
1207
1370
|
const outputMetadata = parseOpenCodeReadMetadata(output);
|
|
1371
|
+
const content = restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
|
|
1208
1372
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1209
1373
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1210
1374
|
// `truncated` alone is not enough: it is set here, and models still assert
|
|
@@ -1452,6 +1616,53 @@ function parseOpenCodeReadMetadata(output) {
|
|
|
1452
1616
|
return { totalLines: Number(complete[1]) };
|
|
1453
1617
|
return {};
|
|
1454
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* OpenCode's numbered read envelope cannot represent the terminator after the
|
|
1621
|
+
* final line. Cursor's legacy edit executor performs exact replacement against
|
|
1622
|
+
* this content, so dropping a real LF/CRLF makes otherwise-valid edits fail and
|
|
1623
|
+
* encourages a whole-file rewrite. Restore only that final terminator for an
|
|
1624
|
+
* unbounded, confirmed-complete read; paged/capped reads stay untouched.
|
|
1625
|
+
*/
|
|
1626
|
+
function restoreCompleteReadTerminator(content, readPath, metadata, resultMetadata, workspaceRoot) {
|
|
1627
|
+
if (!readPath
|
|
1628
|
+
|| metadata.totalLines === undefined
|
|
1629
|
+
|| metadata.startLine !== undefined
|
|
1630
|
+
|| metadata.endLine !== undefined
|
|
1631
|
+
|| num(resultMetadata?.offset) !== undefined
|
|
1632
|
+
|| num(resultMetadata?.limit) !== undefined
|
|
1633
|
+
|| content.endsWith("\n"))
|
|
1634
|
+
return content;
|
|
1635
|
+
const absolute = path.isAbsolute(readPath)
|
|
1636
|
+
? readPath
|
|
1637
|
+
: path.resolve(workspaceRoot ?? process.cwd(), readPath);
|
|
1638
|
+
let fd;
|
|
1639
|
+
try {
|
|
1640
|
+
fd = fs.openSync(absolute, "r");
|
|
1641
|
+
const size = fs.fstatSync(fd).size;
|
|
1642
|
+
if (size === 0)
|
|
1643
|
+
return content;
|
|
1644
|
+
const tail = Buffer.alloc(Math.min(2, size));
|
|
1645
|
+
fs.readSync(fd, tail, 0, tail.length, size - tail.length);
|
|
1646
|
+
if (tail.length >= 2 && tail[tail.length - 2] === 0x0d && tail[tail.length - 1] === 0x0a) {
|
|
1647
|
+
return `${content}\r\n`;
|
|
1648
|
+
}
|
|
1649
|
+
if (tail[tail.length - 1] === 0x0a)
|
|
1650
|
+
return `${content}\n`;
|
|
1651
|
+
}
|
|
1652
|
+
catch {
|
|
1653
|
+
// The host read result remains authoritative when the local path cannot be
|
|
1654
|
+
// inspected (deleted/raced/permission changed after the tool completed).
|
|
1655
|
+
}
|
|
1656
|
+
finally {
|
|
1657
|
+
if (fd !== undefined) {
|
|
1658
|
+
try {
|
|
1659
|
+
fs.closeSync(fd);
|
|
1660
|
+
}
|
|
1661
|
+
catch { /* already closed */ }
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return content;
|
|
1665
|
+
}
|
|
1455
1666
|
/**
|
|
1456
1667
|
* OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
|
|
1457
1668
|
* and cuts on a whole-line boundary, then appends
|
|
@@ -1750,6 +1961,35 @@ function mcpStateToolDefinition(serverIdentifier, descriptor, flatTools) {
|
|
|
1750
1961
|
tool_name: toolName,
|
|
1751
1962
|
};
|
|
1752
1963
|
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Total fallback for Cursor's native MCP-resource exec channel (agent.v1
|
|
1966
|
+
* fields #17/#18, tasks/plans/fix-cursor-mcp-resource-exec.md). Under Option B
|
|
1967
|
+
* `list_mcp_resources`/`read_mcp_resource` execute through the ordinary
|
|
1968
|
+
* field-11 MCP path via the alias rules above, so any 17/18 that still reaches
|
|
1969
|
+
* this provider is Cursor emitting the native variant unsolicited (its own
|
|
1970
|
+
* client owns both executors unconditionally — there is no descriptor to
|
|
1971
|
+
* un-advertise). Cursor's executors are total; mirror that here rather than
|
|
1972
|
+
* guessing a generic success, which risks an endless heartbeat loop.
|
|
1973
|
+
*/
|
|
1974
|
+
export function buildListMcpResourcesFallback(execId) {
|
|
1975
|
+
return encodeMessage("AgentClientMessage", {
|
|
1976
|
+
exec_client_message: {
|
|
1977
|
+
id: execId,
|
|
1978
|
+
list_mcp_resources_exec_result: { success: { resources: [] } },
|
|
1979
|
+
},
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
/** See buildListMcpResourcesFallback. Always an `error`, even for `download_path`. */
|
|
1983
|
+
export function buildReadMcpResourceFallback(execId, server, uri) {
|
|
1984
|
+
return encodeMessage("AgentClientMessage", {
|
|
1985
|
+
exec_client_message: {
|
|
1986
|
+
id: execId,
|
|
1987
|
+
read_mcp_resource_exec_result: {
|
|
1988
|
+
error: { uri, error: `Server "${server}" not found` },
|
|
1989
|
+
},
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1753
1993
|
function recordValue(value) {
|
|
1754
1994
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
1755
1995
|
? value
|
package/dist/session.d.ts
CHANGED
|
@@ -112,6 +112,14 @@ export type CursorSession = {
|
|
|
112
112
|
* or when we bridge the completed display call into an OpenCode tool-call.
|
|
113
113
|
*/
|
|
114
114
|
displayToolCalls: Map<string, Record<string, unknown>>;
|
|
115
|
+
/**
|
|
116
|
+
* Legacy edit calls whose authoritative exec path is still in progress.
|
|
117
|
+
* Cursor implements these as read -> whole-file write; retaining the path
|
|
118
|
+
* lets the pump expose the final mutation to OpenCode as a targeted edit.
|
|
119
|
+
*/
|
|
120
|
+
editToolCalls?: Map<string, {
|
|
121
|
+
path: string;
|
|
122
|
+
}>;
|
|
115
123
|
/** Monotonic synthetic exec ids for bridged (display-only) OpenCode tool calls. */
|
|
116
124
|
nextBridgedExecId: number;
|
|
117
125
|
/** KV blob store: blob_id (hex) → data, for Cursor's out-of-band payload channel. */
|
|
@@ -132,8 +140,8 @@ export type CursorSession = {
|
|
|
132
140
|
allowTools: boolean;
|
|
133
141
|
/**
|
|
134
142
|
* Best-effort token usage for this held-open Run. Updated from text/tool
|
|
135
|
-
* activity and replaced by TurnEnded
|
|
136
|
-
* tool-
|
|
143
|
+
* activity and replaced by TurnEnded only for a single-step Run. Emitted on
|
|
144
|
+
* tool-call finishes so OpenCode does not store all-zero usage mid-loop.
|
|
137
145
|
*/
|
|
138
146
|
usageEstimate: {
|
|
139
147
|
inputTokens: number;
|
|
@@ -142,6 +150,13 @@ export type CursorSession = {
|
|
|
142
150
|
cacheWrite: number;
|
|
143
151
|
reasoningTokens: number;
|
|
144
152
|
};
|
|
153
|
+
/**
|
|
154
|
+
* True after this held Run has already crossed an OpenCode tool-call
|
|
155
|
+
* boundary. Cursor's eventual TurnEnded counters then cover the whole Run,
|
|
156
|
+
* not only the final doStream request, so they cannot be used as that
|
|
157
|
+
* request's context usage.
|
|
158
|
+
*/
|
|
159
|
+
hadToolCallBoundary?: boolean;
|
|
145
160
|
/**
|
|
146
161
|
* True while a doStream pull() is actively reading this session's frames.
|
|
147
162
|
* Prevents a late cancel/abort from a prior ReadableStream from destroying
|
package/dist/usage.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type { LanguageModelV3Usage } from "@ai-sdk/provider";
|
|
|
2
2
|
/** Non-negative integer counter from a Cursor `turn_ended` field. */
|
|
3
3
|
export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
|
|
4
4
|
/**
|
|
5
|
-
* Map
|
|
6
|
-
* flatten via input total = noCache + cacheRead + cacheWrite).
|
|
5
|
+
* Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
|
|
6
|
+
* flatten via input total = noCache + cacheRead + cacheWrite). Only use this
|
|
7
|
+
* for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
|
|
7
8
|
*
|
|
8
9
|
* Contract: `input_tokens` is non-cached input; cache fields are separate.
|
|
9
10
|
* Cursor's agent wire currently sends `cache_write=0` even when later turns
|
|
@@ -18,7 +19,15 @@ export type CursorUsageEstimate = {
|
|
|
18
19
|
cacheWrite: number;
|
|
19
20
|
reasoningTokens: number;
|
|
20
21
|
};
|
|
21
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Request-local usage for a held Cursor Run.
|
|
24
|
+
*
|
|
25
|
+
* `est.inputTokens` tracks the seed plus delivered tool results, while
|
|
26
|
+
* `est.outputTokens` accumulates model output across earlier tool boundaries.
|
|
27
|
+
* Move prior output into the current input estimate and report only this
|
|
28
|
+
* doStream request's output. Cache counters are intentionally absent: the
|
|
29
|
+
* only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
|
|
30
|
+
*/
|
|
22
31
|
export declare function buildLanguageModelV3UsageFromEstimate(est: CursorUsageEstimate, promptTokens: number, outputChars: number, estimateCharsToTokens: (chars: number) => number): LanguageModelV3Usage;
|
|
23
32
|
/** Mirror Kilo `Session.getUsage` / AI SDK flat fields from V3 nested usage. */
|
|
24
33
|
export declare function kiloShapedUsageFromV3(usage: LanguageModelV3Usage): {
|
package/dist/usage.js
CHANGED
|
@@ -6,8 +6,9 @@ export function turnEndedCounter(te, key) {
|
|
|
6
6
|
: 0;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* Map
|
|
10
|
-
* flatten via input total = noCache + cacheRead + cacheWrite).
|
|
9
|
+
* Map request-local Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
|
|
10
|
+
* flatten via input total = noCache + cacheRead + cacheWrite). Only use this
|
|
11
|
+
* for a Run with no prior tool boundary; multi-step TurnEnded is cumulative.
|
|
11
12
|
*
|
|
12
13
|
* Contract: `input_tokens` is non-cached input; cache fields are separate.
|
|
13
14
|
* Cursor's agent wire currently sends `cache_write=0` even when later turns
|
|
@@ -34,27 +35,30 @@ export function buildLanguageModelV3UsageFromTurnEnded(te) {
|
|
|
34
35
|
},
|
|
35
36
|
};
|
|
36
37
|
}
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Request-local usage for a held Cursor Run.
|
|
40
|
+
*
|
|
41
|
+
* `est.inputTokens` tracks the seed plus delivered tool results, while
|
|
42
|
+
* `est.outputTokens` accumulates model output across earlier tool boundaries.
|
|
43
|
+
* Move prior output into the current input estimate and report only this
|
|
44
|
+
* doStream request's output. Cache counters are intentionally absent: the
|
|
45
|
+
* only counters Cursor exposes at TurnEnded are cumulative for the whole Run.
|
|
46
|
+
*/
|
|
38
47
|
export function buildLanguageModelV3UsageFromEstimate(est, promptTokens, outputChars, estimateCharsToTokens) {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
const cacheWrite = est.cacheWrite;
|
|
43
|
-
const outputFromChars = estimateCharsToTokens(outputChars);
|
|
44
|
-
const outputText = est.outputTokens > 0 ? est.outputTokens : outputFromChars;
|
|
45
|
-
const reasoning = est.reasoningTokens;
|
|
46
|
-
const outputTotal = outputText + reasoning;
|
|
48
|
+
const outputText = estimateCharsToTokens(outputChars);
|
|
49
|
+
const priorOutput = Math.max(0, est.outputTokens - outputText);
|
|
50
|
+
const inputNoCache = Math.max(promptTokens, est.inputTokens + priorOutput);
|
|
47
51
|
return {
|
|
48
52
|
inputTokens: {
|
|
49
|
-
total: inputNoCache
|
|
53
|
+
total: inputNoCache,
|
|
50
54
|
noCache: inputNoCache,
|
|
51
|
-
cacheRead,
|
|
52
|
-
cacheWrite,
|
|
55
|
+
cacheRead: 0,
|
|
56
|
+
cacheWrite: 0,
|
|
53
57
|
},
|
|
54
58
|
outputTokens: {
|
|
55
|
-
total:
|
|
59
|
+
total: outputText,
|
|
56
60
|
text: outputText,
|
|
57
|
-
reasoning:
|
|
61
|
+
reasoning: undefined,
|
|
58
62
|
},
|
|
59
63
|
};
|
|
60
64
|
}
|