cursor-opencode-provider 0.2.4 → 0.2.5

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.4"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.5"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -175,7 +175,7 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
175
175
  | `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
176
176
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
177
177
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
178
- | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
178
+ | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) |
179
179
  | `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
180
180
  | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
181
181
 
@@ -219,7 +219,7 @@ OpenCode
219
219
 
220
220
  ### Injected system guidance
221
221
 
222
- The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability and canonical workspace-path grounding. Compaction keeps its dedicated prompt unchanged.
222
+ The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
223
223
 
224
224
  If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
225
225
 
@@ -246,7 +246,11 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
246
246
  | 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
247
  | 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. |
248
248
  | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
249
- | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
249
+ | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
250
+
251
+ ## Security
252
+
253
+ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). See [SECURITY.md](./SECURITY.md) for the trust model and `OPENCODE_DISABLE_PROJECT_CONFIG`.
250
254
 
251
255
  ## Known limitations
252
256
 
@@ -256,12 +260,12 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
256
260
  - **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.
257
261
  - **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.
258
262
  - **Background shells are non-interactive** — Cursor's native background-shell spawn is bridged through OpenCode's foreground-only `bash` tool by detaching the requested command, redirecting its output to `${TMPDIR:-/tmp}/cursor-opencode-bg.*`, and returning the real PID in Cursor's typed field-16 result. Shell-stream requests also preserve Cursor's foreground timeout, `timeout_behavior`, and `hard_timeout`: cancellation becomes a typed aborted exit, while a soft timeout requested with background behavior becomes a typed background handoff. OpenCode's internal `<shell_metadata>` envelope and the bridge's private markers are stripped before the result reaches the UI. 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.
259
- - **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. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
260
- - **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-result text 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.
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.
264
+ - **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.
261
265
  - **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.
262
- - **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the complete OpenCode prompt (including trailing tool results and the live user request), re-advertises the same tools, and preserves compaction's no-execution behavior. A second interruption is returned as an explicit error. Recovery cannot see tokens already streamed to the UI on the interrupted attempt, so mid-generation recovery may briefly duplicate visible text/reasoning even though the rebase prompt asks the model not to repeat completed work.
266
+ - **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the OpenCode prompt, retaining the live user request and trailing tool results as host observations without replaying stale historical tool output, re-advertises the same tools, and preserves compaction's no-execution behavior. A second interruption is returned as an explicit error. Recovery cannot see tokens already streamed to the UI on the interrupted attempt, so mid-generation recovery may briefly duplicate visible text/reasoning even though the rebase prompt asks the model not to repeat completed work.
263
267
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
264
268
 
265
269
  ## License
266
270
 
267
- MIT
271
+ MIT
package/SECURITY.md ADDED
@@ -0,0 +1,42 @@
1
+ # Security notes
2
+
3
+ This provider mirrors OpenCode’s trust model for project configuration. That has implications for what ends up in Cursor context.
4
+
5
+ ## Project `instructions` can include arbitrary local paths
6
+
7
+ OpenCode’s `opencode.json` / `opencode.jsonc` `instructions` array may list:
8
+
9
+ - relative paths / globs (e.g. `.cursor/rules/*.md`)
10
+ - absolute paths
11
+ - home-relative paths (`~/…`)
12
+ - remote URLs (`https://…`; this provider fetches HTTPS only)
13
+
14
+ OpenCode itself expands `~/`, accepts absolute paths, and injects those file contents into the model prompt for **every** provider. This package does the same discovery when building Cursor `RequestContext.rules`.
15
+
16
+ So a project config like:
17
+
18
+ ```json
19
+ {
20
+ "instructions": ["~/.ssh/id_rsa", "/etc/passwd"]
21
+ }
22
+ ```
23
+
24
+ can cause those files to be read and sent to the model provider (OpenCode prompt path and/or this provider’s Cursor `RequestContext`). That is intentional OpenCode parity, not a Cursor-only hole. There is no path allowlist that rejects absolute/`~/` instruction paths (matching OpenCode; OpenCode has not treated this as a defect).
25
+
26
+ Treat project `opencode.json` as **trusted**. Do not open untrusted repositories with project config enabled if that is unacceptable.
27
+
28
+ ## Mitigation: disable project config
29
+
30
+ To ignore project-level OpenCode config (including project `instructions` and project `AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` discovery), set:
31
+
32
+ ```bash
33
+ export OPENCODE_DISABLE_PROJECT_CONFIG=1
34
+ ```
35
+
36
+ OpenCode honors this flag when loading config and assembling prompts. This provider honors the same flag when collecting rules for Cursor `RequestContext`, so project `instructions` are not merged and project instruction files are not auto-discovered. Global config under `~/.config/opencode` (and `~/.claude/CLAUDE.md` when applicable) still applies.
37
+
38
+ Truthy values: `1` or `true` (case-insensitive), same as OpenCode.
39
+
40
+ ## Related hardening in this provider
41
+
42
+ - Remote `instructions` URLs are **HTTPS-only** (`http://` is skipped). Redirects (including to a local proxy) are intentional and not blocked.
package/dist/auth.d.ts CHANGED
@@ -15,6 +15,8 @@ export declare class AuthTimeoutError extends Error {
15
15
  }
16
16
  export declare function isExpiringSoon(jwt: string, thresholdS?: number): boolean;
17
17
  export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
18
+ /** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
19
+ export declare function decodeJwtExpiryMs(jwt: string): number | null;
18
20
  export declare function useAuthToken(token: string): {
19
21
  accessToken: string;
20
22
  };
@@ -45,4 +47,3 @@ export declare function generatePkceParams(): PkceParams;
45
47
  export declare function generatePkceChallenge(verifier: string): Promise<string>;
46
48
  export declare function buildLoginUrl(challenge: string, uuid: string, websiteUrl?: string): string;
47
49
  export declare function pollForTokens(uuid: string, verifier: string, baseUrl?: string, signal?: AbortSignal, maxAttempts?: number): Promise<TokenPair>;
48
- export declare function loginWithBrowser(websiteUrl?: string, apiBaseUrl?: string, signal?: AbortSignal): Promise<TokenPair>;
package/dist/auth.js CHANGED
@@ -32,22 +32,35 @@ export class AuthTimeoutError extends Error {
32
32
  }
33
33
  // ── Helpers ──
34
34
  export function isExpiringSoon(jwt, thresholdS = 300) {
35
- try {
36
- const payload = JSON.parse(atob(jwt.split(".")[1]));
37
- return (payload.exp * 1000 - Date.now()) < thresholdS * 1000;
38
- }
39
- catch {
35
+ const payload = decodeJwtPayload(jwt);
36
+ if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
40
37
  return true;
41
38
  }
39
+ return payload.exp * 1000 - Date.now() < thresholdS * 1000;
42
40
  }
43
41
  export function decodeJwtPayload(jwt) {
44
42
  try {
45
- return JSON.parse(atob(jwt.split(".")[1]));
43
+ const segment = jwt.split(".")[1];
44
+ if (!segment)
45
+ return null;
46
+ const json = Buffer.from(segment, "base64url").toString("utf8");
47
+ const payload = JSON.parse(json);
48
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
49
+ return null;
50
+ return payload;
46
51
  }
47
52
  catch {
48
53
  return null;
49
54
  }
50
55
  }
56
+ /** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
57
+ export function decodeJwtExpiryMs(jwt) {
58
+ const payload = decodeJwtPayload(jwt);
59
+ if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
60
+ return null;
61
+ }
62
+ return payload.exp * 1000;
63
+ }
51
64
  function base64url(bytes) {
52
65
  return btoa(String.fromCharCode(...bytes))
53
66
  .replace(/\+/g, "-")
@@ -191,10 +204,3 @@ export async function pollForTokens(uuid, verifier, baseUrl = API_BASE, signal,
191
204
  }
192
205
  throw new AuthTimeoutError(`Poll timed out after ${maxAttempts} attempts (~5 min)`);
193
206
  }
194
- // ── Combined login (Mode C, one-shot) ──
195
- export async function loginWithBrowser(websiteUrl, apiBaseUrl, signal) {
196
- const params = generatePkceParams();
197
- const challenge = await generatePkceChallenge(params.verifier);
198
- const loginUrl = buildLoginUrl(challenge, params.uuid, websiteUrl);
199
- return await pollForTokens(params.uuid, params.verifier, apiBaseUrl, signal);
200
- }
@@ -9,6 +9,10 @@ export type OpencodeJson = {
9
9
  plugins?: string[];
10
10
  mcp?: Record<string, unknown>;
11
11
  };
12
+ /** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
13
+ export declare function isProjectConfigDisabled(): boolean;
14
+ /** Fetch a remote instruction with one deadline covering headers and body. */
15
+ export declare function fetchRemoteInstruction(url: string, timeoutMs?: number): Promise<string | undefined>;
12
16
  export declare function loadMergedConfig(workspaceRoot: string): Promise<OpencodeJson>;
13
17
  /**
14
18
  * Collect OpenCode instruction files.
@@ -129,8 +129,40 @@ async function expandGlob(pattern, workspaceRoot) {
129
129
  await walk(startDir, 0);
130
130
  return out;
131
131
  }
132
+ /** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
133
+ export function isProjectConfigDisabled() {
134
+ const value = process.env.OPENCODE_DISABLE_PROJECT_CONFIG?.toLowerCase();
135
+ return value === "true" || value === "1";
136
+ }
137
+ /** Fetch a remote instruction with one deadline covering headers and body. */
138
+ export async function fetchRemoteInstruction(url, timeoutMs = 5000) {
139
+ const ctrl = new AbortController();
140
+ const timer = setTimeout(() => ctrl.abort(), Math.max(1, timeoutMs));
141
+ try {
142
+ const res = await fetch(url, { signal: ctrl.signal });
143
+ if (!res.ok)
144
+ return undefined;
145
+ return await res.text();
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
132
154
  export async function loadMergedConfig(workspaceRoot) {
133
155
  const globalConfig = await readJsonConfig(opencodeGlobalConfigDir());
156
+ if (isProjectConfigDisabled()) {
157
+ return {
158
+ ...globalConfig,
159
+ instructions: [...(globalConfig.instructions ?? [])],
160
+ plugin: [...(globalConfig.plugin ?? [])],
161
+ plugins: [...(globalConfig.plugins ?? [])],
162
+ mcp: { ...(globalConfig.mcp ?? {}) },
163
+ permission: globalConfig.permission,
164
+ };
165
+ }
134
166
  const projectConfig = await readJsonConfig(workspaceRoot);
135
167
  return {
136
168
  ...globalConfig,
@@ -162,33 +194,37 @@ export async function collectRules(workspaceRoot) {
162
194
  seen.add(resolved);
163
195
  rules.push(rule);
164
196
  };
165
- for (const name of ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]) {
166
- const hit = await findUp(name, workspaceRoot, worktree);
167
- if (hit) {
168
- await add(hit);
169
- break;
197
+ // Match OpenCode: OPENCODE_DISABLE_PROJECT_CONFIG skips project AGENTS/CLAUDE/CONTEXT
198
+ // discovery and project opencode.json (see loadMergedConfig).
199
+ if (!isProjectConfigDisabled()) {
200
+ for (const name of ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]) {
201
+ const hit = await findUp(name, workspaceRoot, worktree);
202
+ if (hit) {
203
+ await add(hit);
204
+ break;
205
+ }
170
206
  }
171
207
  }
172
208
  await add(path.join(opencodeGlobalConfigDir(), "AGENTS.md"));
173
209
  await add(path.join(homedir(), ".claude", "CLAUDE.md"));
174
210
  for (const raw of config.instructions ?? []) {
175
211
  if (raw.startsWith("http://") || raw.startsWith("https://")) {
212
+ // HTTPS-only. Redirects (incl. to a local proxy) are intentional — do not
213
+ // disable follow-redirects or reject localhost/private/metadata hosts.
214
+ let remoteUrl;
176
215
  try {
177
- const ctrl = new AbortController();
178
- const t = setTimeout(() => ctrl.abort(), 5000);
179
- const res = await fetch(raw, { signal: ctrl.signal });
180
- clearTimeout(t);
181
- if (!res.ok)
182
- continue;
183
- const content = await res.text();
184
- if (!content.trim() || seen.has(raw))
185
- continue;
186
- seen.add(raw);
187
- rules.push({ fullPath: raw, content });
216
+ remoteUrl = new URL(raw);
188
217
  }
189
218
  catch {
190
- /* ignore */
219
+ continue;
191
220
  }
221
+ if (remoteUrl.protocol !== "https:")
222
+ continue;
223
+ const content = await fetchRemoteInstruction(remoteUrl.href);
224
+ if (!content?.trim() || seen.has(raw))
225
+ continue;
226
+ seen.add(raw);
227
+ rules.push({ fullPath: raw, content });
192
228
  continue;
193
229
  }
194
230
  const expanded = resolveHomeRelative(raw);
package/dist/debug.d.ts CHANGED
@@ -1 +1,10 @@
1
+ /** Resolve the debug log path (env override or per-uid tmpdir default). */
2
+ export declare function resolveDebugLogPath(): string;
3
+ /**
4
+ * Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
5
+ * Exported for tests; callers normally go through `trace`.
6
+ */
7
+ export declare function ensureSecureDebugLog(filePath: string, options?: {
8
+ secureParent?: boolean;
9
+ }): void;
1
10
  export declare function trace(msg: string): void;
package/dist/debug.js CHANGED
@@ -1,20 +1,60 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  // Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
3
- // Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
5
+ // Default path mirrors Cursor CLI: $TMPDIR/cursor-provider-logs-<uid>/debug-<pid>.log
6
+ // with directory mode 0o700 and file mode 0o600. Override with CURSOR_PROVIDER_DEBUG_FILE.
4
7
  // Truncated once per process. Tokens / checksums should be redacted by callers.
5
8
  const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
6
9
  process.env.CURSOR_PROVIDER_DEBUG === "true";
7
- const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
8
10
  let _traceInitialized = false;
11
+ let _debugFile;
12
+ let _debugFileUsesManagedDirectory = false;
13
+ /** Resolve the debug log path (env override or per-uid tmpdir default). */
14
+ export function resolveDebugLogPath() {
15
+ if (process.env.CURSOR_PROVIDER_DEBUG_FILE) {
16
+ return process.env.CURSOR_PROVIDER_DEBUG_FILE;
17
+ }
18
+ const uid = typeof process.getuid === "function" ? process.getuid() : process.pid;
19
+ return path.join(os.tmpdir(), `cursor-provider-logs-${uid}`, `debug-${process.pid}.log`);
20
+ }
21
+ /**
22
+ * Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
23
+ * Exported for tests; callers normally go through `trace`.
24
+ */
25
+ export function ensureSecureDebugLog(filePath, options = {}) {
26
+ const dir = path.dirname(filePath);
27
+ const secureParent = options.secureParent ?? true;
28
+ fs.mkdirSync(dir, secureParent
29
+ ? { recursive: true, mode: 0o700 }
30
+ : { recursive: true });
31
+ if (secureParent) {
32
+ const stat = fs.lstatSync(dir);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
34
+ throw new Error(`Debug log directory is not a real directory: ${dir}`);
35
+ }
36
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
37
+ throw new Error(`Debug log directory is not owned by the current user: ${dir}`);
38
+ }
39
+ fs.chmodSync(dir, 0o700);
40
+ }
41
+ fs.writeFileSync(filePath, "", { mode: 0o600 });
42
+ fs.chmodSync(filePath, 0o600);
43
+ }
9
44
  export function trace(msg) {
10
45
  if (!DEBUG_ENABLED)
11
46
  return;
12
47
  try {
48
+ if (!_debugFile) {
49
+ _debugFileUsesManagedDirectory = !process.env.CURSOR_PROVIDER_DEBUG_FILE;
50
+ _debugFile = resolveDebugLogPath();
51
+ }
13
52
  if (!_traceInitialized) {
53
+ ensureSecureDebugLog(_debugFile, { secureParent: _debugFileUsesManagedDirectory });
54
+ fs.writeFileSync(_debugFile, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`, { mode: 0o600 });
14
55
  _traceInitialized = true;
15
- fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
16
56
  }
17
- fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
57
+ fs.appendFileSync(_debugFile, `[${new Date().toISOString()}] ${msg}\n`);
18
58
  }
19
59
  catch {
20
60
  /* ignore */
@@ -87,12 +87,16 @@ export declare function cursorTurnEndedProviderMetadata(te: Record<string, unkno
87
87
  };
88
88
  };
89
89
  /**
90
- * Prior prompt turns for a seed ConversationStateStructure after compaction
91
- * reset. Drops the trailing user message (live action), but preserves tool
92
- * result payloads as labeled assistant observations for Cursor's text history.
90
+ * Prior prompt turns for a seed ConversationStateStructure. Tool results must
91
+ * never be replayed as assistant-authored prose: that teaches the model to
92
+ * counterfeit `Tool result (...)` text instead of emitting a real tool call.
93
+ * Normal rebases omit old results; compaction can retain all results and
94
+ * interrupted continuations retain only the trailing live result suffix as
95
+ * explicit OpenCode-host observations.
93
96
  */
94
97
  export declare function extractPromptHistory(prompt: LanguageModelV3CallOptions["prompt"], options?: {
95
98
  preserveTrailingUser?: boolean;
99
+ toolResults?: "omit" | "all" | "trailing";
96
100
  }): SeedHistoryMessage[];
97
101
  /** OpenCode session id header, if present. */
98
102
  export declare function opencodeSessionKey(callOptions: LanguageModelV3CallOptions): string | undefined;
@@ -439,7 +439,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
439
439
  const systemPrompt = interactionGuidance
440
440
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
441
441
  : baseSystemPrompt;
442
- const history = extractPromptHistory(prompt, { preserveTrailingUser: recovery });
442
+ const history = extractPromptHistory(prompt, {
443
+ preserveTrailingUser: recovery,
444
+ toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
445
+ });
443
446
  await loadAvailableModels();
444
447
  // Resolve the region-specific Run stream origin once per process (memoized
445
448
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -847,6 +850,56 @@ export async function pump(session, controller, ids, abortSignal) {
847
850
  return false;
848
851
  }
849
852
  };
853
+ /**
854
+ * Cursor's streamed edit handshake reads the target before it sends the
855
+ * replacement through write_args. For a new file that read naturally fails,
856
+ * which makes the model abandon the edit and fall back to a shell heredoc.
857
+ * Treat only a missing target correlated to an edit_tool_call as an empty
858
+ * file, allowing Cursor to continue to the ordinary OpenCode write call.
859
+ */
860
+ const recoverMissingEditRead = (parsed, displayCallId) => {
861
+ if (!displayCallId ||
862
+ parsed.resultField !== "read_result" ||
863
+ parsed.toolName !== "read" ||
864
+ !advertisedToolNameSet.has("write"))
865
+ return false;
866
+ const stored = session.displayToolCalls.get(displayCallId);
867
+ const display = parseDisplayToolCall(displayCallId, stored);
868
+ if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
869
+ return false;
870
+ const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
871
+ const editPath = typeof display.args.path === "string" ? display.args.path : "";
872
+ if (!requestedPath || !editPath)
873
+ return false;
874
+ const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
875
+ ? session.requestContext.workspace_project_dir
876
+ : process.cwd();
877
+ const resolvePath = (value) => path.resolve(workspaceRoot, value);
878
+ const absolutePath = resolvePath(requestedPath);
879
+ if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
880
+ return false;
881
+ try {
882
+ for (const frame of buildExecClientMessages({
883
+ execId: parsed.id,
884
+ resultField: parsed.resultField,
885
+ output: "",
886
+ toolName: parsed.toolName,
887
+ resultMetadata: { path: requestedPath },
888
+ })) {
889
+ session.stream.write(frame);
890
+ }
891
+ trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
892
+ `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
893
+ return true;
894
+ }
895
+ catch (e) {
896
+ const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
897
+ trace(`exec: edit read recovery FAILED ${error.message}`);
898
+ safeError(error);
899
+ sessionManager.close(session);
900
+ return true;
901
+ }
902
+ };
850
903
  /** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
851
904
  const closeOpenSpans = () => {
852
905
  for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
@@ -1153,7 +1206,11 @@ export async function pump(session, controller, ids, abortSignal) {
1153
1206
  trace(`exec request_context: replied`);
1154
1207
  }
1155
1208
  catch (e) {
1156
- trace(`exec request_context: write FAILED ${e.message}`);
1209
+ const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
1210
+ trace(`exec request_context: write FAILED ${error.message}`);
1211
+ safeError(error);
1212
+ sessionManager.close(session);
1213
+ return;
1157
1214
  }
1158
1215
  }
1159
1216
  else if (esm.mcp_state_exec_args) {
@@ -1179,10 +1236,6 @@ export async function pump(session, controller, ids, abortSignal) {
1179
1236
  else {
1180
1237
  const parsed = parseExecServerMessage(esm);
1181
1238
  const displayCallId = extractExecDisplayCallId(esm);
1182
- if (displayCallId) {
1183
- session.displayToolCalls.delete(displayCallId);
1184
- trace(`exec: claimed display callId=${displayCallId}`);
1185
- }
1186
1239
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
1187
1240
  if (parsed) {
1188
1241
  if (parsed.localError) {
@@ -1219,6 +1272,12 @@ export async function pump(session, controller, ids, abortSignal) {
1219
1272
  return;
1220
1273
  continue;
1221
1274
  }
1275
+ if (recoverMissingEditRead(parsed, displayCallId))
1276
+ continue;
1277
+ if (displayCallId) {
1278
+ session.displayToolCalls.delete(displayCallId);
1279
+ trace(`exec: claimed display callId=${displayCallId}`);
1280
+ }
1222
1281
  const tc = buildToolCallPart(parsed, session.sessionId);
1223
1282
  if (parsed.resultField === "shell_stream") {
1224
1283
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
@@ -1257,7 +1316,8 @@ export async function pump(session, controller, ids, abortSignal) {
1257
1316
  else if (interactionQuery) {
1258
1317
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
1259
1318
  // SDK has no Cursor-specific UI callback, so answer immediately with the
1260
- // conservative headless policy from protocol/interactions.ts.
1319
+ // conservative headless policy from protocol/interactions.ts (including
1320
+ // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
1261
1321
  try {
1262
1322
  const handled = handleInteractionQuery(interactionQuery, payload);
1263
1323
  session.stream.write(handled.reply);
@@ -1290,7 +1350,13 @@ export async function pump(session, controller, ids, abortSignal) {
1290
1350
  `found=${handled.found} echoed=${!!handled.echoed} ` +
1291
1351
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
1292
1352
  }
1293
- catch { /* stream closed; pump will surface end */ }
1353
+ catch (e) {
1354
+ const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
1355
+ trace(`kv: write FAILED ${error.message}`);
1356
+ safeError(error);
1357
+ sessionManager.close(session);
1358
+ return;
1359
+ }
1294
1360
  }
1295
1361
  }
1296
1362
  }
@@ -1423,6 +1489,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1423
1489
  if (names.has("webfetch")) {
1424
1490
  instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1425
1491
  }
1492
+ if (names.has("write")) {
1493
+ instructions.push(names.has("edit")
1494
+ ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
1495
+ : "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
1496
+ }
1426
1497
  return [
1427
1498
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1428
1499
  `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
@@ -1469,13 +1540,24 @@ export function cursorTurnEndedProviderMetadata(te) {
1469
1540
  };
1470
1541
  }
1471
1542
  /**
1472
- * Prior prompt turns for a seed ConversationStateStructure after compaction
1473
- * reset. Drops the trailing user message (live action), but preserves tool
1474
- * result payloads as labeled assistant observations for Cursor's text history.
1543
+ * Prior prompt turns for a seed ConversationStateStructure. Tool results must
1544
+ * never be replayed as assistant-authored prose: that teaches the model to
1545
+ * counterfeit `Tool result (...)` text instead of emitting a real tool call.
1546
+ * Normal rebases omit old results; compaction can retain all results and
1547
+ * interrupted continuations retain only the trailing live result suffix as
1548
+ * explicit OpenCode-host observations.
1475
1549
  */
1476
1550
  export function extractPromptHistory(prompt, options) {
1477
1551
  const out = [];
1478
- for (const m of prompt) {
1552
+ const toolResults = options?.toolResults ?? "omit";
1553
+ let trailingToolStart = prompt.length;
1554
+ if (toolResults === "trailing") {
1555
+ while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
1556
+ trailingToolStart--;
1557
+ }
1558
+ }
1559
+ for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
1560
+ const m = prompt[messageIndex];
1479
1561
  if (m.role === "system") {
1480
1562
  if (typeof m.content === "string" && m.content.length > 0) {
1481
1563
  out.push({ role: "system", content: m.content });
@@ -1495,18 +1577,26 @@ export function extractPromptHistory(prompt, options) {
1495
1577
  continue;
1496
1578
  }
1497
1579
  if (m.role === "tool" && Array.isArray(m.content)) {
1580
+ if (toolResults === "omit" ||
1581
+ (toolResults === "trailing" && messageIndex < trailingToolStart))
1582
+ continue;
1498
1583
  const results = [];
1499
1584
  for (const part of m.content) {
1500
1585
  const p = part;
1501
1586
  if (p.type !== "tool-result")
1502
1587
  continue;
1503
1588
  const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
1589
+ const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
1504
1590
  const result = toolResultOutputToText(p.output);
1505
- const label = result.isError ? "Tool error" : "Tool result";
1506
- results.push(`${label} (${toolName}):\n${result.text}`);
1591
+ results.push(formatSeedToolObservation({
1592
+ toolName,
1593
+ toolCallId,
1594
+ output: result.text,
1595
+ isError: result.isError,
1596
+ }));
1507
1597
  }
1508
1598
  if (results.length > 0)
1509
- appendSeedHistory(out, "assistant", results.join("\n\n"));
1599
+ appendSeedHistory(out, "user", results.join("\n\n"));
1510
1600
  }
1511
1601
  }
1512
1602
  // Live user message is the Run action, not seed history.
@@ -1515,6 +1605,15 @@ export function extractPromptHistory(prompt, options) {
1515
1605
  }
1516
1606
  return out;
1517
1607
  }
1608
+ function formatSeedToolObservation(input) {
1609
+ const metadata = JSON.stringify({
1610
+ source: "opencode-tool",
1611
+ tool: input.toolName,
1612
+ callId: input.toolCallId,
1613
+ status: input.isError ? "error" : "completed",
1614
+ });
1615
+ return `OpenCode host observation ${metadata}:\n${input.output}`;
1616
+ }
1518
1617
  function extractAssistantHistoryText(msg) {
1519
1618
  const content = msg.content;
1520
1619
  if (typeof content === "string")
package/dist/plugin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
- import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
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
4
  import { opencodeGlobalCacheDir } from "./context/paths.js";
5
5
  import { readStoredAuth } from "./context/auth-store.js";
@@ -275,7 +275,7 @@ export async function CursorPlugin(input) {
275
275
  type: "oauth",
276
276
  access: newTokens.accessToken,
277
277
  refresh: newTokens.refreshToken,
278
- expires: decodeExpFromJwt(newTokens.accessToken),
278
+ expires: decodeJwtExpiryMs(newTokens.accessToken) ?? Date.now(),
279
279
  ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
280
280
  ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
281
281
  });
@@ -397,7 +397,7 @@ export async function CursorPlugin(input) {
397
397
  provider: CURSOR_PROVIDER_ID,
398
398
  access: result.accessToken,
399
399
  refresh: result.refreshToken,
400
- expires: decodeExpFromJwt(result.accessToken),
400
+ expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
401
401
  };
402
402
  },
403
403
  };
@@ -469,9 +469,3 @@ export async function CursorPlugin(input) {
469
469
  },
470
470
  };
471
471
  }
472
- function decodeExpFromJwt(jwt) {
473
- const payload = decodeJwtPayload(jwt);
474
- if (payload && typeof payload.exp === "number")
475
- return payload.exp * 1000;
476
- return Date.now() + 3600_000;
477
- }
@@ -19,5 +19,12 @@ export declare class UnsupportedInteractionQueryError extends Error {
19
19
  * instead of accidentally restoring the heartbeat-only deadlock.
20
20
  */
21
21
  export declare function inspectInteractionQueryWire(agentServerPayload: Uint8Array): InteractionQueryWireInfo;
22
- /** Build the immediate typed response required by Cursor's Run RPC. */
22
+ /**
23
+ * Build the immediate typed response required by Cursor's Run RPC.
24
+ *
25
+ * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
26
+ * here with a conservative headless policy (reject UI-bound prompts; ack a few
27
+ * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
28
+ * CLI headless mode.
29
+ */
23
30
  export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array): HandledInteraction;
@@ -51,7 +51,14 @@ export function inspectInteractionQueryWire(agentServerPayload) {
51
51
  variantName: variantField === undefined ? undefined : variantNames[variantField],
52
52
  };
53
53
  }
54
- /** Build the immediate typed response required by Cursor's Run RPC. */
54
+ /**
55
+ * Build the immediate typed response required by Cursor's Run RPC.
56
+ *
57
+ * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
58
+ * here with a conservative headless policy (reject UI-bound prompts; ack a few
59
+ * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
60
+ * CLI headless mode.
61
+ */
55
62
  export function handleInteractionQuery(query, agentServerPayload) {
56
63
  const info = inspectInteractionQueryWire(agentServerPayload);
57
64
  if (info.id === undefined || info.variantField === undefined || !info.variantName) {
@@ -80,8 +87,14 @@ export function handleInteractionQuery(query, agentServerPayload) {
80
87
  outcome = "rejected";
81
88
  break;
82
89
  case 7:
90
+ // F14: create_plan_request_query auto-ack (Cursor CLI headless parity).
83
91
  // Cursor CLI's headless fallback acknowledges plan creation without a
84
- // client-side URI; the plan remains in conversation state/checkpoints.
92
+ // client-side URI (`success` + empty `plan_uri`); the plan remains in
93
+ // conversation state / checkpoints. This provider mirrors that reply so
94
+ // the Run RPC does not deadlock waiting for UI approval. Impact: Cursor
95
+ // may treat the plan as accepted without an OpenCode UI confirm; local
96
+ // tool execution is still gated by OpenCode permissions. Do not change
97
+ // this to reject/prompt without CLI parity evidence.
85
98
  response = { create_plan_request_response: { result: { success: {}, plan_uri: "" } } };
86
99
  outcome = "acknowledged";
87
100
  break;
@@ -357,8 +357,11 @@ export function createMessageTypes() {
357
357
  { id: 2, name: "content", type: "string" },
358
358
  { id: 3, name: "total_lines", type: "int32" },
359
359
  { id: 4, name: "file_size", type: "int64" },
360
+ { id: 5, name: "data", type: "bytes" },
360
361
  { id: 6, name: "truncated", type: "bool" },
361
- ]);
362
+ { id: 7, name: "output_blob_id", type: "bytes" },
363
+ { id: 8, name: "range_applied", type: "bool" },
364
+ ], [{ name: "output", fields: ["content", "data"] }]);
362
365
  addType(root, "ReadError", [
363
366
  { id: 1, name: "path", type: "string" },
364
367
  { id: 2, name: "error", type: "string" },
@@ -10,8 +10,8 @@ export type RunRequestInput = {
10
10
  systemPrompt?: string;
11
11
  /**
12
12
  * Prior chat turns for a seed ConversationStateStructure (no checkpoint).
13
- * Used after compaction reset so Cursor sees OpenCode's compacted history
14
- * instead of an empty conversation.
13
+ * Tool outputs, when required for compaction/recovery, are represented as
14
+ * user-role OpenCode host observations rather than assistant-authored prose.
15
15
  */
16
16
  history?: SeedHistoryMessage[];
17
17
  /**
@@ -7,7 +7,12 @@ const TODO_STATUS = {
7
7
  3: "completed",
8
8
  4: "cancelled",
9
9
  };
10
- /** Cursor ToolCall oneof field → default OpenCode tool id. */
10
+ /**
11
+ * Cursor ToolCall oneof field → default OpenCode tool id.
12
+ *
13
+ * F11: `delete_tool_call` has no OpenCode builtin; it remaps to bash (see the
14
+ * delete_tool_call branch below for `rm -f -- <path>`).
15
+ */
11
16
  const VARIANT_TO_OPENCODE = {
12
17
  shell_tool_call: "bash",
13
18
  delete_tool_call: "bash",
@@ -121,6 +126,10 @@ export function parseDisplayToolCall(callId, toolCall) {
121
126
  args: mcpArgs,
122
127
  };
123
128
  }
129
+ // create_plan_tool_call here is display-state mirroring into todowrite.
130
+ // Separately, InteractionQuery create_plan_request_query is auto-acked in
131
+ // protocol/interactions.ts (F14 / CLI headless parity) — that ack is not an
132
+ // execution of this display payload.
124
133
  if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
125
134
  const result = asRecord(payload.result);
126
135
  const success = asRecord(result?.success);
@@ -238,6 +247,7 @@ export function parseDisplayToolCall(callId, toolCall) {
238
247
  args: {},
239
248
  };
240
249
  }
250
+ // F11: display-path delete → bash `rm -f` (OpenCode has no delete tool).
241
251
  if (variant === "delete_tool_call") {
242
252
  const path = typeof args.path === "string" ? args.path : "";
243
253
  return {
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import { encodeMessage } from "./messages.js";
2
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
3
4
  import { trace } from "../debug.js";
@@ -133,8 +134,13 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
133
134
  // glob_args on the exec channel — Cursor's EditToolCall is display-only, and
134
135
  // glob/edit from opencode are advertised as MCP tools and arrive as mcp_args.
135
136
  //
136
- // OpenCode has no `ls` / `delete` builtins: ls read (dirs are readable),
137
- // delete bash `rm`. Arg key remapping happens in mapCursorArgsToOpencode.
137
+ // Design note (F11): Cursor has native delete / background-shell tools; OpenCode
138
+ // does not. This provider intentionally remaps:
139
+ // - delete_args → bash `rm -f -- <quoted-path>`
140
+ // - background_shell_spawn_args → bash + nohup detach wrapper
141
+ // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
142
+ // background / detached children can outlive the OpenCode tool call; leftover
143
+ // process cleanup is left to the user / OS. Arg key remapping happens below.
138
144
  const cursorToolToOpencode = {
139
145
  read_args: "read",
140
146
  write_args: "write",
@@ -229,6 +235,9 @@ export function parseExecServerMessage(msg) {
229
235
  if (!resultField)
230
236
  return undefined;
231
237
  const execId = msg.exec_id ?? "";
238
+ // F11: Cursor native background shell → OpenCode bash. The wrapper detaches
239
+ // via nohup immediately; OpenCode only sees the spawn marker (pid + log path).
240
+ // Residual: the child can keep running after this tool call completes.
232
241
  if (execVariant === "background_shell_spawn_args") {
233
242
  const raw = msg.background_shell_spawn_args ?? {};
234
243
  const command = str(raw.command);
@@ -328,9 +337,16 @@ export function parseExecServerMessage(msg) {
328
337
  if (!toolName)
329
338
  return undefined;
330
339
  const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
340
+ const rawArgs = msg[execVariant] ?? {};
331
341
  const resultMetadata = execVariant === "shell_stream_args"
332
- ? shellStreamResultMetadata(msg[execVariant] ?? {})
333
- : undefined;
342
+ ? shellStreamResultMetadata(rawArgs)
343
+ : execVariant === "read_args"
344
+ ? {
345
+ path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
346
+ ...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
347
+ ...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
348
+ }
349
+ : undefined;
334
350
  if (resultMetadata
335
351
  && resultMetadata.timeout_behavior !== 2
336
352
  && typeof resultMetadata.timeout_ms === "number") {
@@ -390,7 +406,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
390
406
  const filePath = str(cleaned.path) ?? str(cleaned.filePath);
391
407
  return { toolName: "read", args: filePath ? { filePath } : {} };
392
408
  }
393
- // Native delete_args → bash rm (OpenCode has no delete builtin).
409
+ // F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
410
+ // we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
411
+ // as any other bash tool call.
394
412
  if (execVariant === "delete_args") {
395
413
  const target = str(cleaned.path) ?? str(cleaned.filePath);
396
414
  return {
@@ -523,10 +541,15 @@ function shellQuote(s) {
523
541
  return `'${s.replace(/'/g, `'\\''`)}'`;
524
542
  }
525
543
  /**
544
+ * F11 / background_shell_spawn_args helper.
545
+ *
526
546
  * OpenCode's bash tool is foreground-only. Detach the requested command inside
527
- * that one foreground call and print a private marker containing the spawned
528
- * PID and log path. With stdin and all output redirected, the host shell can
529
- * return immediately instead of retaining OpenCode's tool pipe.
547
+ * that one foreground call (`nohup … &`) and print a private marker containing
548
+ * the spawned PID and log path. With stdin and all output redirected, the host
549
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
550
+ *
551
+ * Residual: the detached child is not reaped by this provider after OpenCode
552
+ * completes the tool call; cleanup is left to the user / OS.
530
553
  */
531
554
  function buildBackgroundShellCommand(command) {
532
555
  return [
@@ -735,20 +758,29 @@ export function unwrapReadOutput(output) {
735
758
  */
736
759
  export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
737
760
  switch (resultField) {
738
- case "read_result":
761
+ case "read_result": {
762
+ const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
739
763
  if (error)
740
- return { error: { path: "", error } };
764
+ return { error: { path: readPath, error } };
741
765
  // Strip opencode's <path>/<content> envelope so Cursor's model receives
742
766
  // raw file content and can't echo the wrapper into subsequent writes.
743
767
  // reads route here only for native read_args; most arrive via mcp_result.
744
768
  const content = unwrapReadOutput(output);
769
+ const statPath = extractPathTag(output) ?? readPath;
770
+ const outputMetadata = parseOpenCodeReadMetadata(output);
771
+ const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
772
+ const rangeApplied = readRangeApplied(resultMetadata, totalLines);
745
773
  return {
746
774
  success: {
747
- path: extractPathTag(output) ?? "",
775
+ path: readPath,
748
776
  content,
749
- total_lines: countLines(content),
777
+ total_lines: totalLines,
778
+ file_size: readFileSize(statPath),
779
+ truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
780
+ range_applied: rangeApplied,
750
781
  },
751
782
  };
783
+ }
752
784
  case "grep_result": {
753
785
  if (error)
754
786
  return { error: { error } };
@@ -913,6 +945,93 @@ function extractPathTag(output) {
913
945
  const m = output.match(/<path>([^<]+)<\/path>/);
914
946
  return m?.[1];
915
947
  }
948
+ /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
949
+ function parseOpenCodeReadMetadata(output) {
950
+ const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(output);
951
+ if (showing) {
952
+ return {
953
+ startLine: Number(showing[1]),
954
+ endLine: Number(showing[2]),
955
+ ...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
956
+ outputCapped: output.includes("(Output capped at "),
957
+ };
958
+ }
959
+ const complete = /\(End of file - total (\d+) lines?\)/.exec(output);
960
+ if (complete)
961
+ return { totalLines: Number(complete[1]) };
962
+ return {};
963
+ }
964
+ function readRangeApplied(resultMetadata, totalLines) {
965
+ const offset = num(resultMetadata?.offset);
966
+ const limit = num(resultMetadata?.limit);
967
+ if (offset === undefined && limit === undefined)
968
+ return false;
969
+ if (totalLines === 0)
970
+ return false;
971
+ const startLine = offset ?? 1;
972
+ return startLine < 0 || startLine <= totalLines;
973
+ }
974
+ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
975
+ if (outputMetadata.outputCapped)
976
+ return true;
977
+ const returnedEnd = outputMetadata.endLine;
978
+ if (returnedEnd === undefined || totalLines === 0)
979
+ return false;
980
+ const offset = num(resultMetadata?.offset);
981
+ const limit = num(resultMetadata?.limit);
982
+ const startLine = offset ?? 1;
983
+ if (startLine < 0)
984
+ return false;
985
+ const expectedEnd = limit === undefined
986
+ ? totalLines
987
+ : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
988
+ return returnedEnd < expectedEnd;
989
+ }
990
+ function readFileSize(filePath) {
991
+ if (!filePath)
992
+ return 0;
993
+ try {
994
+ return fs.statSync(filePath).size;
995
+ }
996
+ catch {
997
+ return 0;
998
+ }
999
+ }
1000
+ function readFileLineCount(filePath) {
1001
+ if (!filePath)
1002
+ return undefined;
1003
+ let fd;
1004
+ try {
1005
+ fd = fs.openSync(filePath, "r");
1006
+ const buffer = Buffer.allocUnsafe(64 * 1024);
1007
+ let totalBytes = 0;
1008
+ let lines = 1;
1009
+ while (true) {
1010
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
1011
+ if (bytesRead === 0)
1012
+ break;
1013
+ totalBytes += bytesRead;
1014
+ for (let index = 0; index < bytesRead; index++) {
1015
+ if (buffer[index] === 0x0a)
1016
+ lines++;
1017
+ }
1018
+ }
1019
+ return totalBytes === 0 ? 0 : lines;
1020
+ }
1021
+ catch {
1022
+ return undefined;
1023
+ }
1024
+ finally {
1025
+ if (fd !== undefined) {
1026
+ try {
1027
+ fs.closeSync(fd);
1028
+ }
1029
+ catch {
1030
+ /* best effort */
1031
+ }
1032
+ }
1033
+ }
1034
+ }
916
1035
  function extractPathLines(output) {
917
1036
  const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
918
1037
  // Prefer absolute / relative path-looking lines; fall back to all non-empty.
@@ -27,9 +27,16 @@ export declare function shellPolicyFromMetadata(metadata: Record<string, unknown
27
27
  /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
28
28
  export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
29
29
  /**
30
+ * F11 / soft-background helper.
31
+ *
30
32
  * Run a Cursor soft-background command for its foreground window, then leave
31
- * it detached if still alive. The sentinel is removed by the after hook before
32
- * OpenCode stores/renders the result.
33
+ * it detached (`nohup`) if still alive. The sentinel is removed by the after
34
+ * hook before OpenCode stores/renders the result.
35
+ *
36
+ * This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
37
+ * foreground-only bash tool. Residual: after OpenCode returns, the child (and
38
+ * optional hard-timeout watchdog) may still be running; this provider does not
39
+ * reap leftover processes — cleanup is left to the user / OS.
33
40
  */
34
41
  export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
35
42
  /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
@@ -50,9 +50,16 @@ function shellQuote(value) {
50
50
  return `'${value.replace(/'/g, `'\\''`)}'`;
51
51
  }
52
52
  /**
53
+ * F11 / soft-background helper.
54
+ *
53
55
  * Run a Cursor soft-background command for its foreground window, then leave
54
- * it detached if still alive. The sentinel is removed by the after hook before
55
- * OpenCode stores/renders the result.
56
+ * it detached (`nohup`) if still alive. The sentinel is removed by the after
57
+ * hook before OpenCode stores/renders the result.
58
+ *
59
+ * This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
60
+ * foreground-only bash tool. Residual: after OpenCode returns, the child (and
61
+ * optional hard-timeout watchdog) may still be running; this provider does not
62
+ * reap leftover processes — cleanup is left to the user / OS.
56
63
  */
57
64
  export function buildSoftBackgroundCommand(policy) {
58
65
  const polls = Math.ceil(policy.timeoutMs / POLL_INTERVAL_MS);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,7 @@
45
45
  "dist",
46
46
  "LICENSE",
47
47
  "DISCLAIMER.md",
48
+ "SECURITY.md",
48
49
  "README.md"
49
50
  ],
50
51
  "scripts": {
@@ -68,4 +69,4 @@
68
69
  "@types/node": "^22.15.3",
69
70
  "typescript": "^5.8.3"
70
71
  }
71
- }
72
+ }