cursor-opencode-provider 0.2.6 → 0.2.8

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
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
47
47
  }
48
48
  ```
49
49
 
50
- Pin a version if you want: `"cursor-opencode-provider@0.2.6"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.8"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -102,15 +102,26 @@ 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 it reports strong environment, binary, or package identity
113
+ 3. Explicit `$MIMOCODE_HOME` / `KILO_CONFIG_DIR`, then the provider's host-named install path under `$XDG_CACHE_HOME`
114
+ 4. Default `~/.cache/opencode/`
115
+
116
+ Config-directory presence is deliberately ignored: having MiMo or Kilo installed
117
+ must not redirect a native OpenCode process into that host's cache.
118
+
119
+ | Kind | Default (OpenCode) | Notes |
120
+ |------|--------------------|-------|
121
+ | Model / version **cache** | `~/.cache/opencode/` | MiMo: `$MIMOCODE_HOME/cache` or `~/.cache/mimocode/`; Kilo: `~/.cache/kilo/` |
122
+ | Cursor **project metadata** (`agent-tools`, terminals, …) | `~/.cache/opencode/projects/<slug>/` | under `<host-cache>/projects/` |
123
+ | OpenCode **auth** (`auth.json`) | `~/.local/share/opencode/` | `$XDG_DATA_HOME/opencode/` when set |
124
+ | OpenCode **config** (AGENTS, skills, …) | `~/.config/opencode/` | still OpenCode-named for rule discovery |
114
125
 
115
126
  ### Select a model
116
127
 
@@ -157,6 +168,7 @@ const cursor = createCursor({
157
168
  // apiBaseURL: "https://api2.cursor.sh",
158
169
  // agentBaseURL: "https://agentn.us.api5.cursor.sh", // explicit Run host override
159
170
  // telemetryEnabled: true, // opt in to GetServerConfig telemetry
171
+ // cacheDir: "/path/to/host/cache", // optional; else host heuristic / ~/.cache/opencode
160
172
  // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 },
161
173
  // continuation: { heartbeatMs: 5_000, semanticIdleMs: 120_000, hardCapMs: 600_000 },
162
174
  })
@@ -165,7 +177,7 @@ const model = cursor.languageModel("composer-2.5")
165
177
  // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
166
178
  ```
167
179
 
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`.
180
+ 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
181
 
170
182
  ## Environment variables
171
183
 
@@ -176,7 +188,9 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
176
188
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
177
189
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
178
190
  | `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/` |
191
+ | `XDG_CACHE_HOME` | Base for host cache dirs (`$XDG_CACHE_HOME/opencode/`, `…/mimocode/`, or `…/kilo/`) when no explicit `cacheDir` / OCP detect override |
192
+ | `MIMOCODE_HOME` | When set, host cache is `$MIMOCODE_HOME/cache` (MiMo) |
193
+ | `KILO_CONFIG_DIR` | When set, host cache is `$XDG_CACHE_HOME/kilo` |
180
194
  | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
181
195
 
182
196
  `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 +227,7 @@ OpenCode
213
227
  | `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
214
228
  | `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
215
229
  | `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
230
+ | `src/context/paths.ts` | Host cache root + Cursor project metadata under `<host-cache>/projects/<slug>/` |
216
231
  | `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
217
232
  | `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
218
233
  | `src/protocol/` | Protobuf encode/decode, checksum/device ids, exec + display tool-call mapping (`tool-call-bridge.ts`) |
@@ -241,7 +256,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
241
256
  | 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
257
  | 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
258
  | “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. |
259
+ | 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
260
  | 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
261
  | 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
262
  | 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,12 +270,13 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
255
270
  ## Known limitations
256
271
 
257
272
  - **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.
273
+ - **`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
274
  - **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
275
  - **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
276
  - **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`.
263
- - **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.
277
+ - **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.
278
+ - **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`.
279
+ - **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.
264
280
  - **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
281
  - **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.
266
282
  - **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
@@ -268,4 +284,4 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
268
284
 
269
285
  ## License
270
286
 
271
- MIT
287
+ MIT
@@ -1,3 +1,9 @@
1
+ type AgentUrlOptions = {
2
+ apiBaseURL?: string;
3
+ baseURL?: string;
4
+ telemetryEnabled?: boolean;
5
+ timeoutMs?: number;
6
+ };
1
7
  /**
2
8
  * Resolve the Run stream origin for this account via the `GetServerConfig`
3
9
  * Connect RPC. Memoized for the process lifetime.
@@ -8,10 +14,7 @@
8
14
  *
9
15
  * Concurrent callers share a single in-flight fetch.
10
16
  */
11
- export declare function resolveAgentUrl(token: string, options?: {
12
- apiBaseURL?: string;
13
- baseURL?: string;
14
- telemetryEnabled?: boolean;
15
- }): Promise<string>;
17
+ export declare function resolveAgentUrl(token: string, options?: AgentUrlOptions): Promise<string>;
16
18
  /** Reset the in-process memo. Tests only. */
17
19
  export declare function resetAgentUrlCache(): void;
20
+ export {};
package/dist/auth.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { CURSOR_API_HOST, CURSOR_WEBSITE_HOST } from "./shared.js";
2
+ import { withAbortDeadline } from "./deadline.js";
2
3
  const API_BASE = `https://${CURSOR_API_HOST}`;
4
+ const AUTH_REQUEST_TIMEOUT_MS = 5_000;
3
5
  export class AuthExchangeError extends Error {
4
6
  cause;
5
7
  constructor(message, cause) {
@@ -72,37 +74,67 @@ export function useAuthToken(token) {
72
74
  return { accessToken: token };
73
75
  }
74
76
  export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
75
- const res = await fetch(`${baseUrl}/auth/exchange_user_api_key`, {
76
- method: "POST",
77
- headers: {
78
- "Content-Type": "application/json",
79
- Authorization: `Bearer ${apiKey}`,
80
- },
81
- body: "{}",
77
+ return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthExchangeError("API key exchange timed out"), async (signal) => {
78
+ let res;
79
+ try {
80
+ res = await fetch(`${baseUrl}/auth/exchange_user_api_key`, {
81
+ method: "POST",
82
+ headers: {
83
+ "Content-Type": "application/json",
84
+ Authorization: `Bearer ${apiKey}`,
85
+ },
86
+ body: "{}",
87
+ signal,
88
+ });
89
+ }
90
+ catch (cause) {
91
+ throw new AuthExchangeError("API key exchange request failed", cause);
92
+ }
93
+ if (!res.ok) {
94
+ throw new AuthExchangeError(`API key exchange failed: ${res.status} ${res.statusText}`);
95
+ }
96
+ let body;
97
+ try {
98
+ body = await res.json();
99
+ }
100
+ catch (cause) {
101
+ throw new AuthExchangeError("API key exchange returned malformed JSON", cause);
102
+ }
103
+ if (typeof body.accessToken !== "string" || typeof body.refreshToken !== "string") {
104
+ throw new AuthExchangeError("Exchange response missing tokens");
105
+ }
106
+ return { accessToken: body.accessToken, refreshToken: body.refreshToken };
82
107
  });
83
- if (!res.ok) {
84
- throw new AuthExchangeError(`API key exchange failed: ${res.status} ${res.statusText}`);
85
- }
86
- const body = await res.json();
87
- if (!body.accessToken || !body.refreshToken) {
88
- throw new AuthExchangeError("Exchange response missing tokens");
89
- }
90
- return { accessToken: body.accessToken, refreshToken: body.refreshToken };
91
108
  }
92
109
  export async function refreshAccessToken(refreshToken, baseUrl = API_BASE) {
93
- const res = await fetch(`${baseUrl}/auth/token`, {
94
- method: "POST",
95
- headers: { "Content-Type": "application/json" },
96
- body: JSON.stringify({ refreshToken }),
110
+ return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthRefreshError("Token refresh timed out"), async (signal) => {
111
+ let res;
112
+ try {
113
+ res = await fetch(`${baseUrl}/auth/token`, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify({ refreshToken }),
117
+ signal,
118
+ });
119
+ }
120
+ catch (cause) {
121
+ throw new AuthRefreshError("Token refresh request failed", cause);
122
+ }
123
+ if (!res.ok) {
124
+ throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
125
+ }
126
+ let body;
127
+ try {
128
+ body = await res.json();
129
+ }
130
+ catch (cause) {
131
+ throw new AuthRefreshError("Token refresh returned malformed JSON", cause);
132
+ }
133
+ if (typeof body.accessToken !== "string" || typeof body.refreshToken !== "string") {
134
+ throw new AuthRefreshError("Refresh response missing tokens");
135
+ }
136
+ return { accessToken: body.accessToken, refreshToken: body.refreshToken };
97
137
  });
98
- if (!res.ok) {
99
- throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
100
- }
101
- const body = await res.json();
102
- if (!body.accessToken || !body.refreshToken) {
103
- throw new AuthRefreshError("Refresh response missing tokens");
104
- }
105
- return { accessToken: body.accessToken, refreshToken: body.refreshToken };
106
138
  }
107
139
  // Cache JWTs obtained via API-key exchange so doStream doesn't re-exchange
108
140
  // on every turn when the caller only supplied `apiKey`.
@@ -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,13 +53,20 @@ 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: {
57
61
  enabled: true,
58
62
  mcp_descriptors: nested,
59
63
  },
64
+ // This provider always rejects native web_search/web_fetch interaction
65
+ // queries with a headless-UI reason (see interactions.ts). Advertise that
66
+ // unavailability up front so Cursor prefers the collision-safe
67
+ // custom_web* aliases instead of routing through a query doomed to fail.
68
+ web_search_enabled: false,
69
+ web_fetch_enabled: false,
60
70
  // Completeness: true only for sections we actually gathered.
61
71
  rules_info_complete: true,
62
72
  env_info_complete: true,
@@ -73,5 +83,6 @@ export async function buildRequestContext(input) {
73
83
  .map((p) => `opencode-plugin:${p.source}:${p.id}`)
74
84
  .join("\n");
75
85
  }
86
+ traceRequestContextPaths("buildRequestContext", ctx);
76
87
  return ctx;
77
88
  }
@@ -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,44 @@
1
- /** OpenCode global config dir (`~/.config/opencode`). */
1
+ export type HostPathEnv = NodeJS.ProcessEnv;
2
+ type CompatDetectResult = {
3
+ id: string;
4
+ supported: boolean;
5
+ source?: string;
6
+ profile: {
7
+ paths: {
8
+ cacheDir: string;
9
+ };
10
+ };
11
+ };
12
+ type CompatDetector = () => CompatDetectResult;
13
+ /**
14
+ * Pin the process-wide cache root. Highest precedence for {@link opencodeGlobalCacheDir}.
15
+ * Use for host-injected `Path.cache` or an explicit `createCursor({ cacheDir })`.
16
+ */
17
+ export declare function setHostCacheDirOverride(dir: string | undefined): void;
18
+ export declare function getHostCacheDirOverride(): string | undefined;
19
+ /**
20
+ * Resolve the host cache directory without an override.
21
+ *
22
+ * Explicit host environment wins. Otherwise, an installed provider inherits
23
+ * the host-named cache containing its module. A source checkout or otherwise
24
+ * unidentifiable install defaults to OpenCode. Merely having another host's
25
+ * config directory installed is not evidence that it owns this process.
26
+ */
27
+ export declare function resolveHostCacheDir(env?: HostPathEnv, moduleUrl?: string): string;
28
+ /**
29
+ * Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
30
+ * when the host is supported. No-op when OCP is absent or detection fails.
31
+ */
32
+ export declare function adoptCompatHostCacheDir(detector?: CompatDetector): Promise<string | undefined>;
33
+ /** OpenCode / host global config dir (`~/.config/<app>`). Still OpenCode-named for rule discovery. */
2
34
  export declare function opencodeGlobalConfigDir(): string;
3
35
  /**
4
- * OpenCode global cache dir (`~/.cache/opencode`).
5
- * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
36
+ * Host global cache dir for Cursor project metadata + model/version caches.
37
+ *
38
+ * Precedence:
39
+ * 1. {@link setHostCacheDirOverride} / `createCursor({ cacheDir })` (host `Path.cache`)
40
+ * 2. Strong OCP `detect()` identity when {@link adoptCompatHostCacheDir} ran successfully
41
+ * 3. Explicit host environment / provider install path ({@link resolveHostCacheDir})
6
42
  */
7
43
  export declare function opencodeGlobalCacheDir(): string;
8
44
  /**
@@ -11,4 +47,21 @@ export declare function opencodeGlobalCacheDir(): string;
11
47
  * Auth credentials live here in `auth.json`.
12
48
  */
13
49
  export declare function opencodeGlobalDataDir(): string;
50
+ /**
51
+ * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
52
+ * Used for per-workspace metadata under the host cache.
53
+ */
54
+ export declare function slugifyWorkspacePath(workspaceRoot: string): string;
55
+ /**
56
+ * Cursor-style project metadata root for a workspace.
57
+ * Lives at `<host-cache>/projects/<slug>/` (OpenCode / MiMo / Kilo cache root).
58
+ *
59
+ * This is what Cursor's RequestContextEnv.project_folder / MCP
60
+ * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
61
+ * Must NOT be the git workspace, or those dumps land in the repo.
62
+ */
63
+ export declare function opencodeProjectDir(workspaceRoot: string): string;
64
+ /** Ensure {@link opencodeProjectDir} exists (mode 0o700) and return it. */
65
+ export declare function ensureOpencodeProjectDir(workspaceRoot: string): string;
14
66
  export declare function resolveHomeRelative(p: string): string;
67
+ export {};
@@ -1,21 +1,106 @@
1
+ import { createHash } from "node:crypto";
2
+ import { 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 { fileURLToPath } from "node:url";
6
+ import { trace } from "../debug.js";
7
+ /** Explicit host cache root (e.g. Effect v2 `Path.cache`, or `createCursor({ cacheDir })`). */
8
+ let hostCacheDirOverride;
9
+ function resolveHome(env = process.env) {
10
+ return env.HOME || env.USERPROFILE || homedir();
5
11
  }
6
- /** OpenCode global config dir (`~/.config/opencode`). */
12
+ function xdgCacheHome(env = process.env) {
13
+ if (env.XDG_CACHE_HOME && env.XDG_CACHE_HOME.length > 0)
14
+ return env.XDG_CACHE_HOME;
15
+ return path.join(resolveHome(env), ".cache");
16
+ }
17
+ /**
18
+ * Pin the process-wide cache root. Highest precedence for {@link opencodeGlobalCacheDir}.
19
+ * Use for host-injected `Path.cache` or an explicit `createCursor({ cacheDir })`.
20
+ */
21
+ export function setHostCacheDirOverride(dir) {
22
+ hostCacheDirOverride = dir && dir.length > 0 ? path.resolve(dir) : undefined;
23
+ }
24
+ export function getHostCacheDirOverride() {
25
+ return hostCacheDirOverride;
26
+ }
27
+ /**
28
+ * Resolve the host cache directory without an override.
29
+ *
30
+ * Explicit host environment wins. Otherwise, an installed provider inherits
31
+ * the host-named cache containing its module. A source checkout or otherwise
32
+ * unidentifiable install defaults to OpenCode. Merely having another host's
33
+ * config directory installed is not evidence that it owns this process.
34
+ */
35
+ export function resolveHostCacheDir(env = process.env, moduleUrl = import.meta.url) {
36
+ const mimoHome = env.MIMOCODE_HOME;
37
+ if (mimoHome && mimoHome.length > 0) {
38
+ return path.join(mimoHome, "cache");
39
+ }
40
+ const cacheHome = xdgCacheHome(env);
41
+ const kiloConfig = env.KILO_CONFIG_DIR;
42
+ if (kiloConfig && kiloConfig.length > 0) {
43
+ return path.join(cacheHome, "kilo");
44
+ }
45
+ let modulePath;
46
+ try {
47
+ modulePath = moduleUrl.startsWith("file:") ? fileURLToPath(moduleUrl) : path.resolve(moduleUrl);
48
+ }
49
+ catch {
50
+ modulePath = undefined;
51
+ }
52
+ if (modulePath) {
53
+ for (const host of ["mimocode", "kilo", "opencode"]) {
54
+ const root = path.resolve(cacheHome, host);
55
+ if (modulePath === root || modulePath.startsWith(`${root}${path.sep}`))
56
+ return root;
57
+ }
58
+ }
59
+ return path.join(cacheHome, "opencode");
60
+ }
61
+ /**
62
+ * Best-effort: if `@opencode-compat/profile` is installed, adopt `detect().profile.paths.cacheDir`
63
+ * when the host is supported. No-op when OCP is absent or detection fails.
64
+ */
65
+ export async function adoptCompatHostCacheDir(detector) {
66
+ if (hostCacheDirOverride)
67
+ return hostCacheDirOverride;
68
+ try {
69
+ const detect = detector ?? (await import("@opencode-compat/profile")).detect;
70
+ const result = detect();
71
+ if (!result.supported || result.id === "unknown")
72
+ return undefined;
73
+ if (!result.source || !["env", "binary", "package"].includes(result.source)) {
74
+ trace(`host-cache: ignored weak OCP detect host=${result.id} source=${result.source ?? "unknown"}`);
75
+ return undefined;
76
+ }
77
+ const cacheDir = result.profile.paths.cacheDir;
78
+ if (!cacheDir || cacheDir.length === 0)
79
+ return undefined;
80
+ setHostCacheDirOverride(cacheDir);
81
+ trace(`host-cache: adopted OCP detect cacheDir=${cacheDir} host=${result.id}`);
82
+ return cacheDir;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ /** OpenCode / host global config dir (`~/.config/<app>`). Still OpenCode-named for rule discovery. */
7
89
  export function opencodeGlobalConfigDir() {
8
90
  return path.join(resolveHome(), ".config", "opencode");
9
91
  }
10
92
  /**
11
- * OpenCode global cache dir (`~/.cache/opencode`).
12
- * Uses `$XDG_CACHE_HOME/opencode` when set, otherwise `$HOME/.cache/opencode`.
93
+ * Host global cache dir for Cursor project metadata + model/version caches.
94
+ *
95
+ * Precedence:
96
+ * 1. {@link setHostCacheDirOverride} / `createCursor({ cacheDir })` (host `Path.cache`)
97
+ * 2. Strong OCP `detect()` identity when {@link adoptCompatHostCacheDir} ran successfully
98
+ * 3. Explicit host environment / provider install path ({@link resolveHostCacheDir})
13
99
  */
14
100
  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");
101
+ if (hostCacheDirOverride)
102
+ return hostCacheDirOverride;
103
+ return resolveHostCacheDir();
19
104
  }
20
105
  /**
21
106
  * OpenCode global data dir (`~/.local/share/opencode`).
@@ -28,6 +113,47 @@ export function opencodeGlobalDataDir() {
28
113
  }
29
114
  return path.join(resolveHome(), ".local", "share", "opencode");
30
115
  }
116
+ /**
117
+ * Cursor-compatible path slug (`/Users/a/b` → `Users-a-b`).
118
+ * Used for per-workspace metadata under the host cache.
119
+ */
120
+ export function slugifyWorkspacePath(workspaceRoot) {
121
+ const resolved = path.resolve(workspaceRoot);
122
+ return resolved
123
+ .replace(/[^a-zA-Z0-9]/g, "-")
124
+ .replace(/-+/g, "-")
125
+ .replace(/^-+|-+$/g, "");
126
+ }
127
+ /**
128
+ * Cursor-style project metadata root for a workspace.
129
+ * Lives at `<host-cache>/projects/<slug>/` (OpenCode / MiMo / Kilo cache root).
130
+ *
131
+ * This is what Cursor's RequestContextEnv.project_folder / MCP
132
+ * workspace_project_dir point at — agent-tools, terminals, transcripts, etc.
133
+ * Must NOT be the git workspace, or those dumps land in the repo.
134
+ */
135
+ export function opencodeProjectDir(workspaceRoot) {
136
+ const projectsRoot = path.join(opencodeGlobalCacheDir(), "projects");
137
+ const slug = slugifyWorkspacePath(workspaceRoot);
138
+ let dir = path.join(projectsRoot, slug);
139
+ // Mirror Cursor's long-path guard so nested agent-tools paths stay usable.
140
+ if (dir.length > 92) {
141
+ const hash = createHash("sha256").update(dir).digest("hex").slice(0, 7);
142
+ dir = `${dir.slice(0, Math.min(84, dir.length))}-${hash}`;
143
+ }
144
+ return dir;
145
+ }
146
+ /** Ensure {@link opencodeProjectDir} exists (mode 0o700) and return it. */
147
+ export function ensureOpencodeProjectDir(workspaceRoot) {
148
+ const resolved = path.resolve(workspaceRoot);
149
+ const dir = opencodeProjectDir(resolved);
150
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
151
+ trace(`project-dir: workspace=${resolved} slug=${slugifyWorkspacePath(resolved)} ` +
152
+ `dir=${dir} cache_root=${opencodeGlobalCacheDir()} ` +
153
+ `override=${hostCacheDirOverride ?? "(none)"} ` +
154
+ `xdg_cache_home=${process.env.XDG_CACHE_HOME ?? "(unset)"}`);
155
+ return dir;
156
+ }
31
157
  export function resolveHomeRelative(p) {
32
158
  if (p.startsWith("~/"))
33
159
  return path.join(homedir(), p.slice(2));
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Bound a complete async operation, not only fetch(). Response body readers can
3
+ * ignore abort signals, so Promise.race remains the authoritative deadline.
4
+ *
5
+ * Rejects with `timeoutError()` before aborting so callers observe the domain
6
+ * timeout error rather than a generic AbortError.
7
+ */
8
+ export declare function withAbortDeadline<T>(timeoutMs: number, timeoutError: () => Error, run: (signal: AbortSignal) => Promise<T>): Promise<T>;