cursor-opencode-provider 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,15 +102,23 @@ Choose the **cursor** provider, then one of:
102
102
  | **Cursor account (browser login)** | PKCE OAuth — opens cursor.com to sign in |
103
103
  | **API key** | Paste a key from [cursor.com/settings](https://cursor.com/settings) (`sk-...`) |
104
104
 
105
- After login, the plugin fetches your available models and writes them to `~/.cache/opencode/cursor-models.json` (or `$XDG_CACHE_HOME/opencode/` when set). On later startups, a missing, empty, expired, or old-schema cache is refreshed during config load when Cursor auth is available; an existing stale cache remains usable if refresh fails.
105
+ After login, the plugin fetches your available models and writes them to `<host-cache>/cursor-models.json` (default `~/.cache/opencode/`). On later startups, a missing, empty, expired, or old-schema cache is refreshed during config load when Cursor auth is available; an existing stale cache remains usable if refresh fails.
106
106
 
107
- ### Paths (XDG)
107
+ ### Paths (host cache)
108
108
 
109
- | Kind | Default | Override |
110
- |------|---------|----------|
111
- | Model / version **cache** | `~/.cache/opencode/` | `$XDG_CACHE_HOME/opencode/` |
112
- | OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` |
113
- | OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | (config dir helper; not the model cache) |
109
+ Model/version caches and Cursor project metadata live under a **host cache root**, resolved in this order:
110
+
111
+ 1. Explicit `createCursor({ cacheDir })` / Effect v2 `Path.cache`
112
+ 2. Optional `@opencode-compat/profile` `detect()` when that peer is installed and the host is supported
113
+ 3. Local heuristic: `$MIMOCODE_HOME/cache`, then host-named dirs under `$XDG_CACHE_HOME` (Kilo, then MiMo, then OpenCode)
114
+ 4. Default `~/.cache/opencode/`
115
+
116
+ | Kind | Default (OpenCode) | Notes |
117
+ |------|--------------------|-------|
118
+ | Model / version **cache** | `~/.cache/opencode/` | MiMo: `$MIMOCODE_HOME/cache` or `~/.cache/mimocode/`; Kilo: `~/.cache/kilo/` |
119
+ | Cursor **project metadata** (`agent-tools`, terminals, …) | `~/.cache/opencode/projects/<slug>/` | under `<host-cache>/projects/` |
120
+ | OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` when set |
121
+ | OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | still OpenCode-named for rule discovery |
114
122
 
115
123
  ### Select a model
116
124
 
@@ -157,6 +165,7 @@ const cursor = createCursor({
157
165
  // apiBaseURL: "https://api2.cursor.sh",
158
166
  // agentBaseURL: "https://agentn.us.api5.cursor.sh", // explicit Run host override
159
167
  // telemetryEnabled: true, // opt in to GetServerConfig telemetry
168
+ // cacheDir: "/path/to/host/cache", // optional; else host heuristic / ~/.cache/opencode
160
169
  // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 },
161
170
  // continuation: { heartbeatMs: 5_000, semanticIdleMs: 120_000, hardCapMs: 600_000 },
162
171
  })
@@ -165,7 +174,7 @@ const model = cursor.languageModel("composer-2.5")
165
174
  // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
166
175
  ```
167
176
 
168
- Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
177
+ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses OCP `@opencode-compat/profile` detect (if installed) or the local MiMo/Kilo/OpenCode heuristic described in [Paths](#paths-host-cache). Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
169
178
 
170
179
  ## Environment variables
171
180
 
@@ -176,7 +185,9 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
176
185
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
177
186
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
178
187
  | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) |
179
- | `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
188
+ | `XDG_CACHE_HOME` | Base for host cache dirs (`$XDG_CACHE_HOME/opencode/`, `…/mimocode/`, or `…/kilo/`) when no explicit `cacheDir` / OCP detect override |
189
+ | `MIMOCODE_HOME` | When set, host cache is `$MIMOCODE_HOME/cache` (MiMo) |
190
+ | `KILO_CONFIG_DIR` | When set (or `~/.config/kilo` exists), host cache is `$XDG_CACHE_HOME/kilo` |
180
191
  | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
181
192
 
182
193
  `createCursor({ agentBaseURL })` overrides the agent Run host. When unset, the provider resolves the host from Cursor's `GetServerConfig` API (`agentUrlConfig.agentnUrl`, region-specific — e.g. `agentn.us.api5.cursor.sh`, `agent-gcpp-uswest.api5.cursor.sh`) once per process and holds it in memory (never written to disk), so a held-open Run stream is never repointed mid-session. Explicit agent overrides and GetServerConfig results are validated as HTTPS `*.cursor.sh` hosts (Cursor's agent hostnames vary and may change); non-`cursor.sh` hosts are rejected. Shared HTTP/2 connections are rotated before they become server-aged, while existing Runs may finish on their original connection. The lookup sends `{ "telem_enabled": false }` by default; set `telemetryEnabled: true` in provider config, or `CURSOR_GET_SERVER_CONFIG_TELEMETRY=1` for OpenCode/plugin usage, to opt in. If the lookup fails or does not return a valid Cursor agent host, the model call fails clearly instead of falling back to `agentn.global.api5.cursor.sh`.
@@ -213,6 +224,7 @@ OpenCode
213
224
  | `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
214
225
  | `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
215
226
  | `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
227
+ | `src/context/paths.ts` | Host cache root + Cursor project metadata under `<host-cache>/projects/<slug>/` |
216
228
  | `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
217
229
  | `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
218
230
  | `src/protocol/` | Protobuf encode/decode, checksum/device ids, exec + display tool-call mapping (`tool-call-bridge.ts`) |
@@ -241,7 +253,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
241
253
  | No Cursor models in the picker | Confirm Cursor auth (`opencode auth login` → **cursor**). Restart OpenCode — if auth is present and the cache is empty, models are fetched on startup. Confirm `provider.cursor.npm` is the package name (or a built `file://…/dist/index.js`). |
242
254
  | Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
243
255
  | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
244
- | Empty or stale model list | Delete `~/.cache/opencode/cursor-models.json` (or under `$XDG_CACHE_HOME/opencode/`) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
256
+ | Empty or stale model list | Delete `<host-cache>/cursor-models.json` (default `~/.cache/opencode/`, or MiMo/Kilo host cache) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
245
257
  | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, or `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
246
258
  | No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. |
247
259
  | 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. |
@@ -255,11 +267,11 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
255
267
  ## Known limitations
256
268
 
257
269
  - **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.
258
- - **`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. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
270
+ - **`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.
259
271
  - **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.
260
272
  - **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.
261
273
  - **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.
262
- - **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. The OpenCode UI keeps the original command; wrapping happens via the classic plugin's `shell.env` injectors (`BASH_ENV` / `ZDOTDIR`) so permissions still see the real user command. 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`.
274
+ - **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`.
263
275
  - **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 known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and 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.
264
276
  - **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.
265
277
  - **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.
@@ -7,6 +7,8 @@ import { collectPlugins } from "./plugins.js";
7
7
  import { collectGit } from "./git.js";
8
8
  import { collectProjectLayout } from "./layout.js";
9
9
  import { buildEnv } from "./env.js";
10
+ import { ensureOpencodeProjectDir } from "./paths.js";
11
+ import { traceRequestContextPaths } from "../debug.js";
10
12
  /**
11
13
  * Full RequestContext payload for live UMA + exec #10 reply.
12
14
  * Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
@@ -27,6 +29,7 @@ export async function buildRequestContext(input) {
27
29
  const mcpServerNames = Object.keys(config.mcp ?? {});
28
30
  const flat = toolsToDescriptors(tools, providerIdentifier, mcpServerNames);
29
31
  const nested = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames);
32
+ const projectDir = ensureOpencodeProjectDir(workspaceRoot);
30
33
  const ctx = {
31
34
  env: buildEnv(workspaceRoot),
32
35
  tools: flat,
@@ -50,7 +53,8 @@ export async function buildRequestContext(input) {
50
53
  })),
51
54
  mcp_file_system_options: {
52
55
  enabled: true,
53
- workspace_project_dir: workspaceRoot,
56
+ // Cursor metadata root (mcps / agent-tools), not the git workspace.
57
+ workspace_project_dir: projectDir,
54
58
  mcp_descriptors: nested,
55
59
  },
56
60
  mcp_meta_tool_options: {
@@ -73,5 +77,6 @@ export async function buildRequestContext(input) {
73
77
  .map((p) => `opencode-plugin:${p.source}:${p.id}`)
74
78
  .join("\n");
75
79
  }
80
+ traceRequestContextPaths("buildRequestContext", ctx);
76
81
  return ctx;
77
82
  }
@@ -1 +1,7 @@
1
1
  export declare function buildEnv(workspaceRoot: string): Record<string, unknown>;
2
+ /**
3
+ * Real workspace root for path resolution (edits, reads, …).
4
+ * Uses `env.workspace_paths[0]` — never `project_folder` /
5
+ * `mcp_file_system_options.workspace_project_dir` (those are Cursor metadata roots).
6
+ */
7
+ export declare function workspaceRootFromRequestContext(requestContext: Record<string, unknown> | undefined): string;
@@ -1,5 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import path from "node:path";
3
+ import { trace } from "../debug.js";
4
+ import { ensureOpencodeProjectDir } from "./paths.js";
3
5
  export function buildEnv(workspaceRoot) {
4
6
  const cwd = path.resolve(workspaceRoot);
5
7
  let timeZone = "UTC";
@@ -15,15 +17,45 @@ export function buildEnv(workspaceRoot) {
15
17
  const r = process.release?.version;
16
18
  return r ? `${p} ${r}` : p;
17
19
  })();
18
- return {
20
+ // Cursor's project_folder is a metadata root (agent-tools, terminals, …),
21
+ // not the git workspace. Keep dumps under OpenCode cache.
22
+ const projectFolder = ensureOpencodeProjectDir(cwd);
23
+ const env = {
19
24
  os_version: osVersion,
20
25
  workspace_paths: [cwd],
21
26
  shell: process.env.SHELL || "/bin/bash",
22
27
  sandbox_enabled: false,
23
28
  sandbox_supported: false,
24
29
  time_zone: timeZone,
25
- project_folder: cwd,
30
+ project_folder: projectFolder,
31
+ terminals_folder: path.join(projectFolder, "terminals"),
32
+ agent_transcripts_folder: path.join(projectFolder, "agent-transcripts"),
26
33
  process_working_directory: process.cwd(),
27
34
  is_working_dir_home_dir: path.resolve(process.cwd()) === path.resolve(home),
28
35
  };
36
+ trace(`buildEnv: workspace_paths=${JSON.stringify(env.workspace_paths)} ` +
37
+ `project_folder=${env.project_folder} ` +
38
+ `terminals_folder=${env.terminals_folder} ` +
39
+ `agent_transcripts_folder=${env.agent_transcripts_folder} ` +
40
+ `process_working_directory=${env.process_working_directory}`);
41
+ return env;
42
+ }
43
+ /**
44
+ * Real workspace root for path resolution (edits, reads, …).
45
+ * Uses `env.workspace_paths[0]` — never `project_folder` /
46
+ * `mcp_file_system_options.workspace_project_dir` (those are Cursor metadata roots).
47
+ */
48
+ export function workspaceRootFromRequestContext(requestContext) {
49
+ const env = requestContext?.env;
50
+ if (env && typeof env === "object") {
51
+ const paths = env.workspace_paths;
52
+ if (Array.isArray(paths) && typeof paths[0] === "string" && paths[0].trim()) {
53
+ const root = path.resolve(paths[0]);
54
+ trace(`workspaceRootFromRequestContext: using workspace_paths[0]=${root}`);
55
+ return root;
56
+ }
57
+ }
58
+ const fallback = process.cwd();
59
+ trace(`workspaceRootFromRequestContext: workspace_paths missing; fallback cwd=${fallback}`);
60
+ return fallback;
29
61
  }
@@ -1,2 +1,2 @@
1
1
  export { buildRequestContext, type BuildRequestContextInput } from "./build.js";
2
- export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
2
+ export { adoptCompatHostCacheDir, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
@@ -1,2 +1,2 @@
1
1
  export { buildRequestContext } from "./build.js";
2
- export { opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir } from "./paths.js";
2
+ export { adoptCompatHostCacheDir, getHostCacheDirOverride, opencodeGlobalCacheDir, opencodeGlobalConfigDir, opencodeGlobalDataDir, resolveHostCacheDir, setHostCacheDirOverride, } from "./paths.js";
@@ -1,8 +1,30 @@
1
- /** OpenCode global config dir (`~/.config/opencode`). */
1
+ export type HostPathEnv = NodeJS.ProcessEnv;
2
+ /**
3
+ * Pin the process-wide cache root. Highest precedence for {@link opencodeGlobalCacheDir}.
4
+ * Use for host-injected `Path.cache` or an explicit `createCursor({ cacheDir })`.
5
+ */
6
+ export declare function setHostCacheDirOverride(dir: string | undefined): void;
7
+ export declare function getHostCacheDirOverride(): string | undefined;
8
+ /**
9
+ * Resolve the host cache directory without an override.
10
+ * Mirrors OCP HostProfile drafts: MiMo (`mimocode` / `$MIMOCODE_HOME`), Kilo (`kilo`),
11
+ * then OpenCode (`opencode`). Prefer fork config dirs when several exist.
12
+ */
13
+ export declare function resolveHostCacheDir(env?: HostPathEnv): string;
14
+ /**
15
+ * Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
16
+ * when the host is supported. No-op when OCP is absent or detection fails.
17
+ */
18
+ export declare function adoptCompatHostCacheDir(): Promise<string | undefined>;
19
+ /** OpenCode / host global config dir (`~/.config/<app>`). Still OpenCode-named for rule discovery. */
2
20
  export declare function opencodeGlobalConfigDir(): string;
3
21
  /**
4
- * OpenCode global cache dir (`~/.cache/opencode`).
5
- * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
22
+ * Host global cache dir for Cursor project metadata + model/version caches.
23
+ *
24
+ * Precedence:
25
+ * 1. {@link setHostCacheDirOverride} / `createCursor({ cacheDir })` (host `Path.cache`)
26
+ * 2. OCP `detect()` when {@link adoptCompatHostCacheDir} ran successfully
27
+ * 3. Local host heuristic ({@link resolveHostCacheDir})
6
28
  */
7
29
  export declare function opencodeGlobalCacheDir(): string;
8
30
  /**
@@ -11,4 +33,20 @@ export declare function opencodeGlobalCacheDir(): string;
11
33
  * Auth credentials live here in `auth.json`.
12
34
  */
13
35
  export declare function opencodeGlobalDataDir(): string;
36
+ /**
37
+ * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
38
+ * Used for per-workspace metadata under the host cache.
39
+ */
40
+ export declare function slugifyWorkspacePath(workspaceRoot: string): string;
41
+ /**
42
+ * Cursor-style project metadata root for a workspace.
43
+ * Lives at `<host-cache>/projects/<slug>/` (OpenCode / MiMo / Kilo cache root).
44
+ *
45
+ * This is what Cursor's RequestContextEnv.project_folder / MCP
46
+ * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
47
+ * Must NOT be the git workspace, or those dumps land in the repo.
48
+ */
49
+ export declare function opencodeProjectDir(workspaceRoot: string): string;
50
+ /** Ensure {@link opencodeProjectDir} exists (mode 0o700) and return it. */
51
+ export declare function ensureOpencodeProjectDir(workspaceRoot: string): string;
14
52
  export declare function resolveHomeRelative(p: string): string;
@@ -1,21 +1,92 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync } from "node:fs";
1
3
  import { homedir } from "node:os";
2
4
  import path from "node:path";
3
- function resolveHome() {
4
- return process.env.HOME || process.env.USERPROFILE || homedir();
5
+ import { trace } from "../debug.js";
6
+ /** Explicit host cache root (e.g. Effect v2 `Path.cache`, or `createCursor({ cacheDir })`). */
7
+ let hostCacheDirOverride;
8
+ function resolveHome(env = process.env) {
9
+ return env.HOME || env.USERPROFILE || homedir();
5
10
  }
6
- /** OpenCode global config dir (`~/.config/opencode`). */
11
+ function xdgCacheHome(env = process.env) {
12
+ if (env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.length > 0)
13
+ return env.XDG_CACHE_HOME;
14
+ return path.join(resolveHome(env), ".cache");
15
+ }
16
+ function xdgConfigHome(env = process.env) {
17
+ if (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.length > 0)
18
+ return env.XDG_CONFIG_HOME;
19
+ return path.join(resolveHome(env), ".config");
20
+ }
21
+ /**
22
+ * Pin the process-wide cache root. Highest precedence for {@link opencodeGlobalCacheDir}.
23
+ * Use for host-injected `Path.cache` or an explicit `createCursor({ cacheDir })`.
24
+ */
25
+ export function setHostCacheDirOverride(dir) {
26
+ hostCacheDirOverride = dir && dir.length > 0 ? path.resolve(dir) : undefined;
27
+ }
28
+ export function getHostCacheDirOverride() {
29
+ return hostCacheDirOverride;
30
+ }
31
+ /**
32
+ * Resolve the host cache directory without an override.
33
+ * Mirrors OCP HostProfile drafts: MiMo (`mimocode` / `$MIMOCODE_HOME`), Kilo (`kilo`),
34
+ * then OpenCode (`opencode`). Prefer fork config dirs when several exist.
35
+ */
36
+ export function resolveHostCacheDir(env = process.env) {
37
+ const mimoHome = env.MIMOCODE_HOME;
38
+ if (mimoHome && mimoHome.length > 0) {
39
+ return path.join(mimoHome, "cache");
40
+ }
41
+ const configHome = xdgConfigHome(env);
42
+ const cacheHome = xdgCacheHome(env);
43
+ const kiloConfig = env.KILO_CONFIG_DIR;
44
+ // Prefer more specific fork dirs before OpenCode (same order as OCP detect).
45
+ if ((kiloConfig && kiloConfig.length > 0) || existsSync(path.join(configHome, "kilo"))) {
46
+ return path.join(cacheHome, "kilo");
47
+ }
48
+ if (existsSync(path.join(configHome, "mimocode"))) {
49
+ return path.join(cacheHome, "mimocode");
50
+ }
51
+ return path.join(cacheHome, "opencode");
52
+ }
53
+ /**
54
+ * Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
55
+ * when the host is supported. No-op when OCP is absent or detection fails.
56
+ */
57
+ export async function adoptCompatHostCacheDir() {
58
+ try {
59
+ const { detect } = await import("@opencode-compat/profile");
60
+ const result = detect();
61
+ if (!result.supported || result.id === "unknown")
62
+ return undefined;
63
+ const cacheDir = result.profile.paths.cacheDir;
64
+ if (!cacheDir || cacheDir.length === 0)
65
+ return undefined;
66
+ setHostCacheDirOverride(cacheDir);
67
+ trace(`host-cache: adopted OCP detect cacheDir=${cacheDir} host=${result.id}`);
68
+ return cacheDir;
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ /** OpenCode / host global config dir (`~/.config/<app>`). Still OpenCode-named for rule discovery. */
7
75
  export function opencodeGlobalConfigDir() {
8
76
  return path.join(resolveHome(), ".config", "opencode");
9
77
  }
10
78
  /**
11
- * OpenCode global cache dir (`~/.cache/opencode`).
12
- * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
79
+ * Host global cache dir for Cursor project metadata + model/version caches.
80
+ *
81
+ * Precedence:
82
+ * 1. {@link setHostCacheDirOverride} / `createCursor({ cacheDir })` (host `Path.cache`)
83
+ * 2. OCP `detect()` when {@link adoptCompatHostCacheDir} ran successfully
84
+ * 3. Local host heuristic ({@link resolveHostCacheDir})
13
85
  */
14
86
  export function opencodeGlobalCacheDir() {
15
- if (process.env.XDG_CACHE_HOME) {
16
- return path.join(process.env.XDG_CACHE_HOME, "opencode");
17
- }
18
- return path.join(resolveHome(), ".cache", "opencode");
87
+ if (hostCacheDirOverride)
88
+ return hostCacheDirOverride;
89
+ return resolveHostCacheDir();
19
90
  }
20
91
  /**
21
92
  * OpenCode global data dir (`~/.local/share/opencode`).
@@ -28,6 +99,47 @@ export function opencodeGlobalDataDir() {
28
99
  }
29
100
  return path.join(resolveHome(), ".local", "share", "opencode");
30
101
  }
102
+ /**
103
+ * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
104
+ * Used for per-workspace metadata under the host cache.
105
+ */
106
+ export function slugifyWorkspacePath(workspaceRoot) {
107
+ const resolved = path.resolve(workspaceRoot);
108
+ return resolved
109
+ .replace(/[^a-zA-Z0-9]/g, "-")
110
+ .replace(/-+/g, "-")
111
+ .replace(/^-+|-+$/g, "");
112
+ }
113
+ /**
114
+ * Cursor-style project metadata root for a workspace.
115
+ * Lives at `<host-cache>/projects/<slug>/` (OpenCode / MiMo / Kilo cache root).
116
+ *
117
+ * This is what Cursor's RequestContextEnv.project_folder / MCP
118
+ * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
119
+ * Must NOT be the git workspace, or those dumps land in the repo.
120
+ */
121
+ export function opencodeProjectDir(workspaceRoot) {
122
+ const projectsRoot = path.join(opencodeGlobalCacheDir(), "projects");
123
+ const slug = slugifyWorkspacePath(workspaceRoot);
124
+ let dir = path.join(projectsRoot, slug);
125
+ // Mirror Cursor's long-path guard so nested agent-tools paths stay usable.
126
+ if (dir.length > 92) {
127
+ const hash = createHash("sha256").update(dir).digest("hex").slice(0, 7);
128
+ dir = `${dir.slice(0, Math.min(84, dir.length))}-${hash}`;
129
+ }
130
+ return dir;
131
+ }
132
+ /** Ensure {@link opencodeProjectDir} exists (mode 0o700) and return it. */
133
+ export function ensureOpencodeProjectDir(workspaceRoot) {
134
+ const resolved = path.resolve(workspaceRoot);
135
+ const dir = opencodeProjectDir(resolved);
136
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
137
+ trace(`project-dir: workspace=${resolved} slug=${slugifyWorkspacePath(resolved)} ` +
138
+ `dir=${dir} cache_root=${opencodeGlobalCacheDir()} ` +
139
+ `override=${hostCacheDirOverride ?? "(none)"} ` +
140
+ `xdg_cache_home=${process.env.XDG_CACHE_HOME ?? "(unset)"}`);
141
+ return dir;
142
+ }
31
143
  export function resolveHomeRelative(p) {
32
144
  if (p.startsWith("~/"))
33
145
  return path.join(homedir(), p.slice(2));
package/dist/debug.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /** Whether `CURSOR_PROVIDER_DEBUG` is enabled for this process. */
2
+ export declare function isDebugEnabled(): boolean;
1
3
  /** Resolve the debug log path (env override or per-uid tmpdir default). */
2
4
  export declare function resolveDebugLogPath(): string;
3
5
  /**
@@ -8,3 +10,8 @@ export declare function ensureSecureDebugLog(filePath: string, options?: {
8
10
  secureParent?: boolean;
9
11
  }): void;
10
12
  export declare function trace(msg: string): void;
13
+ /**
14
+ * Compact path-advertising summary for RequestContext troubleshooting.
15
+ * Safe: no tokens / file contents — only workspace vs metadata roots.
16
+ */
17
+ export declare function traceRequestContextPaths(label: string, requestContext: Record<string, unknown> | undefined): void;
package/dist/debug.js CHANGED
@@ -10,6 +10,11 @@ const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
10
10
  let _traceInitialized = false;
11
11
  let _debugFile;
12
12
  let _debugFileUsesManagedDirectory = false;
13
+ let _announcedLogPath = false;
14
+ /** Whether `CURSOR_PROVIDER_DEBUG` is enabled for this process. */
15
+ export function isDebugEnabled() {
16
+ return DEBUG_ENABLED;
17
+ }
13
18
  /** Resolve the debug log path (env override or per-uid tmpdir default). */
14
19
  export function resolveDebugLogPath() {
15
20
  if (process.env.CURSOR_PROVIDER_DEBUG_FILE) {
@@ -41,6 +46,18 @@ export function ensureSecureDebugLog(filePath, options = {}) {
41
46
  fs.writeFileSync(filePath, "", { mode: 0o600 });
42
47
  fs.chmodSync(filePath, 0o600);
43
48
  }
49
+ function announceLogPath(filePath) {
50
+ if (_announcedLogPath)
51
+ return;
52
+ _announcedLogPath = true;
53
+ try {
54
+ // Visible in the OpenCode / terminal session so operators know where to look.
55
+ console.error(`[cursor-provider] CURSOR_PROVIDER_DEBUG logging to ${filePath}`);
56
+ }
57
+ catch {
58
+ /* ignore */
59
+ }
60
+ }
44
61
  export function trace(msg) {
45
62
  if (!DEBUG_ENABLED)
46
63
  return;
@@ -53,6 +70,10 @@ export function trace(msg) {
53
70
  ensureSecureDebugLog(_debugFile, { secureParent: _debugFileUsesManagedDirectory });
54
71
  fs.writeFileSync(_debugFile, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`, { mode: 0o600 });
55
72
  _traceInitialized = true;
73
+ announceLogPath(_debugFile);
74
+ fs.appendFileSync(_debugFile, `[${new Date().toISOString()}] debug: enabled file=${_debugFile} ` +
75
+ `xdg_cache_home=${process.env.XDG_CACHE_HOME ?? "(unset)"} ` +
76
+ `cwd=${process.cwd()}\n`);
56
77
  }
57
78
  fs.appendFileSync(_debugFile, `[${new Date().toISOString()}] ${msg}\n`);
58
79
  }
@@ -60,3 +81,24 @@ export function trace(msg) {
60
81
  /* ignore */
61
82
  }
62
83
  }
84
+ /**
85
+ * Compact path-advertising summary for RequestContext troubleshooting.
86
+ * Safe: no tokens / file contents — only workspace vs metadata roots.
87
+ */
88
+ export function traceRequestContextPaths(label, requestContext) {
89
+ if (!DEBUG_ENABLED)
90
+ return;
91
+ const env = requestContext?.env && typeof requestContext.env === "object"
92
+ ? requestContext.env
93
+ : undefined;
94
+ const mcp = requestContext?.mcp_file_system_options &&
95
+ typeof requestContext.mcp_file_system_options === "object"
96
+ ? requestContext.mcp_file_system_options
97
+ : undefined;
98
+ trace(`${label}: workspace_paths=${JSON.stringify(env?.workspace_paths ?? null)} ` +
99
+ `process_working_directory=${JSON.stringify(env?.process_working_directory ?? null)} ` +
100
+ `project_folder=${JSON.stringify(env?.project_folder ?? null)} ` +
101
+ `terminals_folder=${JSON.stringify(env?.terminals_folder ?? null)} ` +
102
+ `agent_transcripts_folder=${JSON.stringify(env?.agent_transcripts_folder ?? null)} ` +
103
+ `workspace_project_dir=${JSON.stringify(mcp?.workspace_project_dir ?? null)}`);
104
+ }
package/dist/index.d.ts CHANGED
@@ -23,6 +23,12 @@ export type CreateCursorOptions = {
23
23
  telemetryEnabled?: boolean;
24
24
  /** OpenCode project / worktree directory for request_context collectors. */
25
25
  workspaceRoot?: string;
26
+ /**
27
+ * Host cache root for Cursor project metadata + model/version caches.
28
+ * Prefer the host's Path.cache (Effect v2) when available; otherwise the
29
+ * provider resolves OpenCode / MiMo / Kilo XDG cache dirs automatically.
30
+ */
31
+ cacheDir?: string;
26
32
  /** Held-stream policy. Defaults: heartbeat 5s, semantic idle 120s, tool inactivity 10m. */
27
33
  continuation?: CursorContinuationOptions;
28
34
  /** Fresh-turn retry policy. Defaults: 3 attempts, 500ms base, 8000ms cap. */
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
5
- import { trace } from "./debug.js";
5
+ 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 { decodeMessage } from "./protocol/messages.js";
@@ -18,7 +18,8 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
18
18
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
19
19
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
20
20
  import { buildRequestContext } from "./context/build.js";
21
- import { opencodeGlobalCacheDir } from "./context/paths.js";
21
+ import { workspaceRootFromRequestContext } from "./context/env.js";
22
+ import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
22
23
  import { resolveAgentUrl } from "./agent-url.js";
23
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
24
25
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
@@ -224,6 +225,9 @@ function rememberPostCompactionRebase(sessionKey) {
224
225
  }
225
226
  }
226
227
  export function createCursorLanguageModel(modelId, providerId, options) {
228
+ // Host Path.cache / explicit override wins over XDG heuristics for this process.
229
+ if (options.cacheDir)
230
+ setHostCacheDirOverride(options.cacheDir);
227
231
  return {
228
232
  specificationVersion: "v3",
229
233
  provider: providerId,
@@ -889,9 +893,9 @@ export async function pump(session, controller, ids, abortSignal) {
889
893
  const editPath = typeof display.args.path === "string" ? display.args.path : "";
890
894
  if (!requestedPath || !editPath)
891
895
  return false;
892
- const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
893
- ? session.requestContext.workspace_project_dir
894
- : process.cwd();
896
+ // Prefer env.workspace_paths project_folder / workspace_project_dir are
897
+ // Cursor metadata roots under <host-cache>/projects/, not the git tree.
898
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
895
899
  const resolvePath = (value) => path.resolve(workspaceRoot, value);
896
900
  const absolutePath = resolvePath(requestedPath);
897
901
  if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
@@ -1221,6 +1225,7 @@ export async function pump(session, controller, ids, abortSignal) {
1221
1225
  trace(`exec request_context hooks_additional_context: ${hooks}`);
1222
1226
  }
1223
1227
  try {
1228
+ traceRequestContextPaths(`exec request_context reply id=${esmId}`, session.requestContext);
1224
1229
  session.stream.write(buildRequestContextResult(esmId, session.requestContext));
1225
1230
  trace(`exec request_context: replied`);
1226
1231
  }
package/dist/plugin-v2.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { define } from "@opencode-ai/plugin/v2/promise";
2
2
  import { CURSOR_PROVIDER_ID } from "./shared.js";
3
3
  import { createCursorLanguageModel } from "./language-model.js";
4
+ import { adoptCompatHostCacheDir } from "./context/paths.js";
4
5
  /**
5
6
  * OpenCode Effect / Promise v2 plugin.
6
7
  *
@@ -25,6 +26,9 @@ function createSdk(options) {
25
26
  export default define({
26
27
  id: "cursor.provider",
27
28
  setup: async (ctx) => {
29
+ // Prefer OCP HostProfile.cacheDir when present; else XDG host heuristic.
30
+ // Explicit createCursor({ cacheDir }) / event.options.cacheDir still wins via LM.
31
+ await adoptCompatHostCacheDir();
28
32
  await ctx.aisdk.sdk((event) => {
29
33
  if (event.sdk)
30
34
  return;
package/dist/plugin.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
2
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
3
  import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
4
- import { opencodeGlobalCacheDir } from "./context/paths.js";
4
+ import { adoptCompatHostCacheDir, opencodeGlobalCacheDir } from "./context/paths.js";
5
5
  import { readStoredAuth } from "./context/auth-store.js";
6
6
  import { resolveAgentUrl } from "./agent-url.js";
7
- import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
7
+ import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
8
8
  import { sessionActivity } from "./activity.js";
9
9
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
10
10
  /**
@@ -194,6 +194,8 @@ function cursorGetServerConfigTelemetryEnabled() {
194
194
  process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
195
195
  }
196
196
  export async function CursorPlugin(input) {
197
+ // Prefer OCP HostProfile.cacheDir when the compat package is present; else XDG heuristic.
198
+ await adoptCompatHostCacheDir();
197
199
  const cacheDir = opencodeGlobalCacheDir();
198
200
  const apiBaseURL = cursorApiBaseURL();
199
201
  // Last access token successfully resolved in this plugin instance. Config's
@@ -336,9 +338,9 @@ export async function CursorPlugin(input) {
336
338
  async "tool.execute.before"(hookInput, output) {
337
339
  if (hookInput.tool !== "bash")
338
340
  return;
339
- // Keep args.command as the original display/permission command. Wrapping
340
- // happens via shell.env (BASH_ENV / ZDOTDIR) so OpenCode's bash UI never
341
- // stores or renders the private wrapper script.
341
+ // bash/zsh retain the original display/permission command and wrap via
342
+ // shell.env. sh/dash need a short wrapper-file command because their
343
+ // non-interactive `-c` path ignores BASH_ENV / ZDOTDIR.
342
344
  prepareCursorShellArgs(hookInput.callID, output.args);
343
345
  },
344
346
  async "shell.env"(hookInput, output) {
@@ -377,6 +379,7 @@ export async function CursorPlugin(input) {
377
379
  }
378
380
  },
379
381
  async config(cfg) {
382
+ setCursorShellPath(cfg.shell);
380
383
  cfg.provider ??= {};
381
384
  const models = await loadModels();
382
385
  const existing = cfg.provider[CURSOR_PROVIDER_ID];
@@ -486,6 +489,7 @@ export async function CursorPlugin(input) {
486
489
  return {
487
490
  ...(accessToken ? { accessToken } : {}),
488
491
  workspaceRoot: input.directory,
492
+ cacheDir,
489
493
  };
490
494
  },
491
495
  },
@@ -653,8 +653,10 @@ export function createMessageTypes() {
653
653
  { id: 2, name: "workspace_paths", type: "string", repeated: true },
654
654
  { id: 3, name: "shell", type: "string" },
655
655
  { id: 5, name: "sandbox_enabled", type: "bool" },
656
+ { id: 7, name: "terminals_folder", type: "string" },
656
657
  { id: 10, name: "time_zone", type: "string" },
657
658
  { id: 11, name: "project_folder", type: "string" },
659
+ { id: 12, name: "agent_transcripts_folder", type: "string" },
658
660
  { id: 14, name: "sandbox_supported", type: "bool" },
659
661
  { id: 20, name: "is_working_dir_home_dir", type: "bool" },
660
662
  { id: 21, name: "process_working_directory", type: "string" },
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import { encodeMessage } from "./messages.js";
3
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
4
- import { trace } from "../debug.js";
4
+ import { buildEnv } from "../context/env.js";
5
+ import { ensureOpencodeProjectDir } from "../context/paths.js";
6
+ import { trace, traceRequestContextPaths } from "../debug.js";
5
7
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
6
- import { BACKGROUND_SHELL_MARKER } from "../shell-timeout.js";
8
+ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
7
9
  // Exec variant field number whose reply is the server-initiated request_context
8
10
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
9
11
  // field number for every exec variant, so this is also the result field.
@@ -104,19 +106,12 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
104
106
  const flat = toolsToDescriptors(tools, providerIdentifier, knownMcpServers);
105
107
  const nested = toolsToMcpDescriptors(tools, providerIdentifier, knownMcpServers);
106
108
  const cwd = process.cwd();
107
- return {
108
- env: {
109
- os_version: process.platform,
110
- workspace_paths: [cwd],
111
- shell: process.env.SHELL || "/bin/bash",
112
- time_zone: "UTC",
113
- project_folder: cwd,
114
- process_working_directory: cwd,
115
- },
109
+ const ctx = {
110
+ env: buildEnv(cwd),
116
111
  tools: flat,
117
112
  mcp_file_system_options: {
118
113
  enabled: true,
119
- workspace_project_dir: cwd,
114
+ workspace_project_dir: ensureOpencodeProjectDir(cwd),
120
115
  mcp_descriptors: nested,
121
116
  },
122
117
  mcp_meta_tool_options: {
@@ -129,6 +124,8 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
129
124
  mcp_file_system_info_complete: true,
130
125
  git_status_info_complete: true,
131
126
  };
127
+ traceRequestContextPaths("buildLiveRequestContext", ctx);
128
+ return ctx;
132
129
  }
133
130
  // ── Cursor exec-variant → opencode tool name ──
134
131
  // Native ExecServerMessage variants only (agent.v1). There is no edit_args or
@@ -235,16 +232,17 @@ export function parseExecServerMessage(msg) {
235
232
  if (!resultField)
236
233
  return undefined;
237
234
  const execId = msg.exec_id ?? "";
238
- // F11: Cursor native background shell → OpenCode bash. Emit the original
239
- // command for UI/storage; the classic plugin before-hook wraps with nohup.
240
- // Residual: the child can keep running after this tool call completes.
235
+ // F11: Cursor native background shell → OpenCode bash. Keep the wrapper
236
+ // self-contained so direct provider / hosts without shell.env still detach
237
+ // and return a PID. The classic plugin replaces this with its display-safe
238
+ // env or wrapper-file path before execution.
241
239
  if (execVariant === "background_shell_spawn_args") {
242
240
  const raw = msg.background_shell_spawn_args ?? {};
243
241
  const command = str(raw.command);
244
242
  const workingDirectory = str(raw.working_directory) ?? "";
245
243
  const args = {};
246
244
  if (command)
247
- args.command = command;
245
+ args.command = buildBackgroundShellCommand(command);
248
246
  if (workingDirectory)
249
247
  args.workdir = workingDirectory;
250
248
  return {
@@ -1112,6 +1110,7 @@ export function detectExecVariantField(agentServerPayload) {
1112
1110
  * Encode exec #10 request_context_result from a prebuilt RequestContext payload.
1113
1111
  */
1114
1112
  export function buildRequestContextResult(execId, requestContext) {
1113
+ traceRequestContextPaths(`buildRequestContextResult id=${execId}`, requestContext);
1115
1114
  return encodeMessage("AgentClientMessage", {
1116
1115
  exec_client_message: {
1117
1116
  id: execId,
@@ -27,6 +27,13 @@ export type CursorShellOutcome = {
27
27
  msToWait: number;
28
28
  reason: 1;
29
29
  };
30
+ /** Track OpenCode's configured shell from the classic config hook. */
31
+ export declare function setCursorShellPath(shell: string | undefined): void;
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export declare function resolveCursorShellKind(shell?: string | undefined): "bash" | "zsh" | "sh" | "dash" | "other";
30
37
  export declare function shellPolicyFromMetadata(metadata: Record<string, unknown> | undefined): CursorShellPolicy | undefined;
31
38
  /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
32
39
  export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
@@ -58,15 +65,13 @@ export declare function buildBackgroundShellCommand(command: string): string;
58
65
  /**
59
66
  * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
60
67
  *
61
- * Important: do **not** replace `args.command` with the wrapper script.
62
- * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
63
- * persists the execute-time `args` object into that field. Mutating
64
- * `args.command` therefore leaks the private wrapper into the TUI/GUI.
68
+ * bash/zsh source the shell.env injector, so the original command remains in
69
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
70
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
65
71
  *
66
- * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
67
- * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
68
- * replaced with the wrapper while the stored/displayed command stays original.
69
- * Permissions also keep analyzing the real user command.
72
+ * background_shell_spawn may already contain the inline non-plugin fallback.
73
+ * The classic hook replaces it with the original command (bash/zsh) or the
74
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
70
75
  */
71
76
  export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
72
77
  /** Restore the model-facing command in OpenCode's completed tool title. */
@@ -74,8 +79,8 @@ export declare function cursorShellOriginalCommand(toolCallId: string): string |
74
79
  /** Drop injector temp files for a finished/abandoned Cursor shell call. */
75
80
  export declare function releaseCursorShellEnv(toolCallId: string): void;
76
81
  /**
77
- * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
78
- * wrapper while `args.command` (and therefore the bash UI) stay original.
82
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
83
+ * same materialized wrapper backs the direct-command sh/dash fallback.
79
84
  */
80
85
  export declare function cursorShellEnvForCall(toolCallId: string | undefined): Record<string, string> | undefined;
81
86
  /**
@@ -1,6 +1,6 @@
1
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
- import { join } from "node:path";
3
+ import { basename, delimiter, join } from "node:path";
4
4
  /** Cursor agent.v1 TimeoutBehavior enum values. */
5
5
  export const CURSOR_TIMEOUT_CANCEL = 1;
6
6
  export const CURSOR_TIMEOUT_BACKGROUND = 2;
@@ -14,9 +14,34 @@ const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
14
14
  export const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
15
15
  const policies = new Map();
16
16
  const outcomes = new Map();
17
- /** callIDs that need shell.env injectors (so args.command can stay display-original). */
17
+ /** callIDs that need shell.env injectors or a direct-command fallback. */
18
18
  const pendingEnvWraps = new Set();
19
19
  const activeEnvWraps = new Map();
20
+ let configuredShell;
21
+ /** Track OpenCode's configured shell from the classic config hook. */
22
+ export function setCursorShellPath(shell) {
23
+ configuredShell = shell?.trim() || undefined;
24
+ }
25
+ function executableOnPath(name) {
26
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
27
+ if (dir && existsSync(join(dir, name)))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export function resolveCursorShellKind(shell = configuredShell ?? process.env.SHELL) {
37
+ let name = shell ? basename(shell).toLowerCase().replace(/\.exe$/, "") : "";
38
+ if (name === "fish" || name === "nu" || !name) {
39
+ name = executableOnPath("bash") ? "bash" : "sh";
40
+ }
41
+ if (name === "bash" || name === "zsh" || name === "sh" || name === "dash")
42
+ return name;
43
+ return "other";
44
+ }
20
45
  function remember(map, key, value, onEvict) {
21
46
  map.delete(key);
22
47
  map.set(key, value);
@@ -128,37 +153,12 @@ export function buildBackgroundShellCommand(command) {
128
153
  `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
129
154
  ].join("\n");
130
155
  }
131
- /**
132
- * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
133
- *
134
- * Important: do **not** replace `args.command` with the wrapper script.
135
- * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
136
- * persists the execute-time `args` object into that field. Mutating
137
- * `args.command` therefore leaks the private wrapper into the TUI/GUI.
138
- *
139
- * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
140
- * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
141
- * replaced with the wrapper while the stored/displayed command stays original.
142
- * Permissions also keep analyzing the real user command.
143
- */
144
- export function prepareCursorShellArgs(toolCallId, args) {
145
- const policy = policies.get(toolCallId);
146
- if (!policy)
147
- return;
148
- if (policy.backgroundSpawn) {
149
- pendingEnvWraps.add(toolCallId);
150
- return;
151
- }
152
- if (policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
153
- return;
154
- pendingEnvWraps.add(toolCallId);
155
- // The wrapper returns just after Cursor's foreground window. OpenCode's own
156
- // timeout is only an outer safety net and must not win the race.
157
- args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
158
- }
159
- /** Restore the model-facing command in OpenCode's completed tool title. */
160
- export function cursorShellOriginalCommand(toolCallId) {
161
- return policies.get(toolCallId)?.command || undefined;
156
+ function wrapperBodyForPolicy(policy) {
157
+ if (policy.backgroundSpawn)
158
+ return buildBackgroundShellCommand(policy.command);
159
+ if (policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)
160
+ return buildSoftBackgroundCommand(policy);
161
+ return undefined;
162
162
  }
163
163
  function writeShellEnvInjector(wrapperBody) {
164
164
  const dir = mkdtempSync(join(tmpdir(), "cursor-opencode-wrap-"));
@@ -166,13 +166,8 @@ function writeShellEnvInjector(wrapperBody) {
166
166
  const bashEnvPath = join(dir, "bashenv.sh");
167
167
  const zshenvPath = join(dir, ".zshenv");
168
168
  writeFileSync(wrapperPath, `${wrapperBody}\n`, { mode: 0o700 });
169
- // Sourced by non-interactive bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR).
170
- // `exec` replaces the host shell so OpenCode's `-c <original>` body never runs.
171
- //
172
- // Do not gate on a sticky env flag: soft-background children inherit the
173
- // wrapper environment, and a leftover CURSOR_OPENCODE_WRAP_ACTIVE=1 would
174
- // make later injectors no-op (original `-c` runs unwrapped). Unsetting the
175
- // injector vars before `exec` is enough to prevent re-entry.
169
+ // Sourced by bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR). `exec`
170
+ // replaces the host shell before OpenCode's `-c <original>` body runs.
176
171
  const injector = [
177
172
  "unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
178
173
  `exec /bin/sh ${shellQuote(wrapperPath)}`,
@@ -181,6 +176,7 @@ function writeShellEnvInjector(wrapperBody) {
181
176
  writeFileSync(bashEnvPath, injector, { mode: 0o600 });
182
177
  writeFileSync(zshenvPath, injector, { mode: 0o600 });
183
178
  return {
179
+ wrapperPath,
184
180
  env: {
185
181
  BASH_ENV: bashEnvPath,
186
182
  ZDOTDIR: dir,
@@ -195,6 +191,55 @@ function writeShellEnvInjector(wrapperBody) {
195
191
  },
196
192
  };
197
193
  }
194
+ function ensureShellEnvWrap(toolCallId, policy) {
195
+ const existing = activeEnvWraps.get(toolCallId);
196
+ if (existing)
197
+ return existing;
198
+ const wrapperBody = wrapperBodyForPolicy(policy);
199
+ if (!wrapperBody)
200
+ return undefined;
201
+ const wrap = writeShellEnvInjector(wrapperBody);
202
+ remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
203
+ return wrap;
204
+ }
205
+ /**
206
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
207
+ *
208
+ * bash/zsh source the shell.env injector, so the original command remains in
209
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
210
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
211
+ *
212
+ * background_shell_spawn may already contain the inline non-plugin fallback.
213
+ * The classic hook replaces it with the original command (bash/zsh) or the
214
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
215
+ */
216
+ export function prepareCursorShellArgs(toolCallId, args) {
217
+ const policy = policies.get(toolCallId);
218
+ if (!policy)
219
+ return;
220
+ if (!policy.backgroundSpawn && policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
221
+ return;
222
+ pendingEnvWraps.add(toolCallId);
223
+ if (!policy.backgroundSpawn) {
224
+ // The wrapper returns just after Cursor's foreground window. OpenCode's own
225
+ // timeout is only an outer safety net and must not win the race.
226
+ args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
227
+ }
228
+ const shellKind = resolveCursorShellKind();
229
+ if (process.platform === "win32" || shellKind === "bash" || shellKind === "zsh") {
230
+ // Native Windows PowerShell/cmd wrapping remains unsupported; do not emit
231
+ // a POSIX /bin/sh command there. Git Bash still uses the env path above.
232
+ args.command = policy.command;
233
+ return;
234
+ }
235
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
236
+ if (wrap)
237
+ args.command = `exec /bin/sh ${shellQuote(wrap.wrapperPath)}`;
238
+ }
239
+ /** Restore the model-facing command in OpenCode's completed tool title. */
240
+ export function cursorShellOriginalCommand(toolCallId) {
241
+ return policies.get(toolCallId)?.command || undefined;
242
+ }
198
243
  /** Drop injector temp files for a finished/abandoned Cursor shell call. */
199
244
  export function releaseCursorShellEnv(toolCallId) {
200
245
  pendingEnvWraps.delete(toolCallId);
@@ -205,8 +250,8 @@ export function releaseCursorShellEnv(toolCallId) {
205
250
  active.cleanup();
206
251
  }
207
252
  /**
208
- * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
209
- * wrapper while `args.command` (and therefore the bash UI) stay original.
253
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
254
+ * same materialized wrapper backs the direct-command sh/dash fallback.
210
255
  */
211
256
  export function cursorShellEnvForCall(toolCallId) {
212
257
  if (typeof toolCallId !== "string" || !toolCallId || !pendingEnvWraps.has(toolCallId))
@@ -214,18 +259,9 @@ export function cursorShellEnvForCall(toolCallId) {
214
259
  const policy = policies.get(toolCallId);
215
260
  if (!policy)
216
261
  return undefined;
217
- const existing = activeEnvWraps.get(toolCallId);
218
- if (existing)
219
- return existing.env;
220
- const wrapperBody = policy.backgroundSpawn
221
- ? buildBackgroundShellCommand(policy.command)
222
- : policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
223
- ? buildSoftBackgroundCommand(policy)
224
- : undefined;
225
- if (!wrapperBody)
262
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
263
+ if (!wrap)
226
264
  return undefined;
227
- const wrap = writeShellEnvInjector(wrapperBody);
228
- remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
229
265
  pendingEnvWraps.delete(toolCallId);
230
266
  return wrap.env;
231
267
  }
@@ -430,4 +466,5 @@ export function resetCursorShellCalls() {
430
466
  pendingEnvWraps.clear();
431
467
  policies.clear();
432
468
  outcomes.clear();
469
+ configuredShell = undefined;
433
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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,7 +61,13 @@
61
61
  "protobufjs": "^7.4.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.17.13"
64
+ "@opencode-ai/plugin": "^1.17.13",
65
+ "@opencode-compat/profile": ">=0.1.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@opencode-compat/profile": {
69
+ "optional": true
70
+ }
65
71
  },
66
72
  "devDependencies": {
67
73
  "@opencode-ai/plugin": "^1.17.13",
@@ -69,4 +75,4 @@
69
75
  "@types/node": "^22.15.3",
70
76
  "typescript": "^5.8.3"
71
77
  }
72
- }
78
+ }