cursor-opencode-provider 0.1.4 → 0.1.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
@@ -18,7 +18,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
18
18
  - **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
19
19
  - **Model discovery** — fetches available models from Cursor's API and caches them locally
20
20
  - **Streaming** — bidirectional Connect-RPC stream for agent runs
21
- - **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts
21
+ - **Tool calls** — maps Cursor exec-server messages to AI SDK tool-call parts; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
22
22
  - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
23
23
 
24
24
  ## Requirements
@@ -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.1.4"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.1.5"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -181,6 +181,7 @@ OpenCode
181
181
  | `src/index.ts` | `createCursor` factory; default export is `CursorPlugin` |
182
182
  | `src/language-model.ts` | AI SDK `LanguageModelV3` adapter (`doStream`, `doGenerate`) |
183
183
  | `src/session.ts` | Held-open agent Run session and pending exec correlation |
184
+ | `src/debug.ts` | Opt-in wire-level debug logging (`CURSOR_PROVIDER_DEBUG`) |
184
185
  | `src/auth.ts` | PKCE OAuth, API key exchange, JWT refresh |
185
186
  | `src/models.ts` | `AvailableModels` fetch and `cursor-models.json` cache |
186
187
  | `src/agent-url.ts` | `GetServerConfig` fetch + in-process memo (region-specific Run host) |
@@ -218,4 +219,4 @@ OpenCode
218
219
 
219
220
  ## License
220
221
 
221
- MIT
222
+ MIT
package/dist/agent-url.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { fetchAgentUrl, trace } from "./transport/connect.js";
2
+ import { fetchAgentUrl } from "./transport/connect.js";
3
+ import { trace } from "./debug.js";
3
4
  import { CURSOR_API_HOST } from "./shared.js";
4
5
  const DEFAULT_API_BASE = `https://${CURSOR_API_HOST}`;
5
6
  // In-process memo of the region-specific Run stream origin (agentnUrl). Resolved
@@ -0,0 +1 @@
1
+ export declare function trace(msg: string): void;
package/dist/debug.js ADDED
@@ -0,0 +1,22 @@
1
+ import fs from "node:fs";
2
+ // 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).
4
+ // Truncated once per process. Tokens / checksums should be redacted by callers.
5
+ const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
6
+ process.env.CURSOR_PROVIDER_DEBUG === "true";
7
+ const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
8
+ let _traceInitialized = false;
9
+ export function trace(msg) {
10
+ if (!DEBUG_ENABLED)
11
+ return;
12
+ try {
13
+ if (!_traceInitialized) {
14
+ _traceInitialized = true;
15
+ fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
16
+ }
17
+ fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
18
+ }
19
+ catch {
20
+ /* ignore */
21
+ }
22
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { bidiRunStream, normalizeAgentRunOrigin, trace } from "./transport/connect.js";
3
+ import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
4
+ import { trace } from "./debug.js";
4
5
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
5
6
  import { decodeFramePayload } from "./protocol/framing.js";
6
7
  import { decodeMessage } from "./protocol/messages.js";
@@ -78,6 +79,7 @@ async function doStreamImpl(modelId, options, callOptions) {
78
79
  resultField: pending.resultField,
79
80
  output: r.output,
80
81
  error: r.error,
82
+ toolName: pending.toolName ?? r.toolName,
81
83
  })) {
82
84
  session.stream.write(frame);
83
85
  }
@@ -575,6 +577,7 @@ async function pump(session, controller, ids, abortSignal) {
575
577
  resultField: parsed.resultField,
576
578
  output: "",
577
579
  error: reason,
580
+ toolName: parsed.toolName,
578
581
  })) {
579
582
  session.stream.write(frame);
580
583
  }
@@ -586,7 +589,7 @@ async function pump(session, controller, ids, abortSignal) {
586
589
  }
587
590
  const tc = buildToolCallPart(parsed, session.sessionId);
588
591
  // Keep the stream open; the result arrives on the next doStream call.
589
- sessionManager.registerPending(parsed.id, session, parsed.resultField);
592
+ sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
590
593
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
591
594
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
592
595
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
@@ -53,6 +53,12 @@ export type ToolResultInput = {
53
53
  output: string;
54
54
  error?: string;
55
55
  executionTimeMs?: number;
56
+ /**
57
+ * Resolved opencode tool name (read/write/grep/…). Gates read-envelope
58
+ * unwrapping on the mcp_result path so non-read MCP output is never
59
+ * rewritten. read_result is always a read, so it unwraps regardless.
60
+ */
61
+ toolName?: string;
56
62
  };
57
63
  /**
58
64
  * Build one or more ExecClientMessage frames for a tool result.
@@ -64,12 +70,33 @@ export type ToolResultInput = {
64
70
  export declare function buildExecClientMessages(input: ToolResultInput): Uint8Array[];
65
71
  /** ACM #5 exec_client_control_message { stream_close { id } }. */
66
72
  export declare function buildExecStreamClose(execId: number): Uint8Array;
73
+ /**
74
+ * Strip opencode's `read` envelope, leaving raw file content.
75
+ *
76
+ * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
77
+ * envelope its own models are trained on, but Cursor's are not:
78
+ * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
79
+ * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
80
+ * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
81
+ * back into files (silent corruption — the write still reports success; seen
82
+ * across 8+ sessions, e.g. language-model.ts rewritten starting with
83
+ * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
84
+ *
85
+ * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
86
+ *
87
+ * Deliberately exception-safe: if the expected envelope is absent — non-read
88
+ * output, already-raw text, or a future opencode format change — it returns the
89
+ * input unchanged, so a result is never broken and we never throw mid-turn.
90
+ * Callers must still gate `mcp_result` on `toolName === "read"`; this helper
91
+ * alone is not a tool-identity check.
92
+ */
93
+ export declare function unwrapReadOutput(output: string): string;
67
94
  /**
68
95
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
69
96
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
70
97
  * server accepts (verified against agent.v1 wire captures).
71
98
  */
72
- export declare function buildTypedExecResult(resultField: string, output: string, error?: string): Record<string, unknown>;
99
+ export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string): Record<string, unknown>;
73
100
  export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
74
101
  toolCallId: string;
75
102
  toolName: string;
@@ -1,5 +1,6 @@
1
1
  import { encodeMessage } from "./messages.js";
2
2
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
3
+ import { trace } from "../debug.js";
3
4
  // Exec variant field number whose reply is the server-initiated request_context
4
5
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
5
6
  // field number for every exec variant, so this is also the result field.
@@ -394,7 +395,7 @@ export function buildExecClientMessages(input) {
394
395
  id: input.execId,
395
396
  local_execution_time_ms: input.executionTimeMs ?? 0,
396
397
  };
397
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error);
398
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName);
398
399
  frames.push(encodeMessage("AgentClientMessage", {
399
400
  exec_client_message: clientMsg,
400
401
  }));
@@ -411,21 +412,90 @@ export function buildExecStreamClose(execId) {
411
412
  },
412
413
  });
413
414
  }
415
+ /**
416
+ * Strip opencode's `read` envelope, leaving raw file content.
417
+ *
418
+ * opencode's read tool (opencode `tool/read.ts`) wraps content in an XML-ish
419
+ * envelope its own models are trained on, but Cursor's are not:
420
+ * <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
421
+ * Forwarding that envelope verbatim made Cursor's model treat the wrapper as
422
+ * literal file content and write `<path>`/`<content>` tags + `N:` line prefixes
423
+ * back into files (silent corruption — the write still reports success; seen
424
+ * across 8+ sessions, e.g. language-model.ts rewritten starting with
425
+ * `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
426
+ *
427
+ * Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
428
+ *
429
+ * Deliberately exception-safe: if the expected envelope is absent — non-read
430
+ * output, already-raw text, or a future opencode format change — it returns the
431
+ * input unchanged, so a result is never broken and we never throw mid-turn.
432
+ * Callers must still gate `mcp_result` on `toolName === "read"`; this helper
433
+ * alone is not a tool-identity check.
434
+ */
435
+ export function unwrapReadOutput(output) {
436
+ if (typeof output !== "string" || output.length === 0)
437
+ return output;
438
+ // Require the full opencode read-envelope skeleton *before* `<content>`
439
+ // (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
440
+ // stray "<content>" later in tool chatter can't trigger unwrapping just
441
+ // because path/type tags appear elsewhere in the payload.
442
+ const contentHeaderIdx = output.indexOf("<content>");
443
+ if (contentHeaderIdx === -1)
444
+ return output;
445
+ const header = output.slice(0, contentHeaderIdx);
446
+ const hasSkeleton = header.indexOf("<path>") !== -1 &&
447
+ header.indexOf("<type>file</type>") !== -1;
448
+ if (!hasSkeleton) {
449
+ // Saw "<content>" but not the read skeleton ahead of it — almost certainly
450
+ // a non-read payload, or an opencode format drift. Surface it so drift
451
+ // can't silently resurrect the wrapper-corruption bug, but still fail
452
+ // safe (no mutate).
453
+ trace("unwrapReadOutput: <content> present without leading <path>/<type>file> skeleton — leaving output unchanged (possible non-read payload or opencode read format drift)");
454
+ return output;
455
+ }
456
+ // Body starts right after "<content>\n". opencode emits one numbered line per
457
+ // file line ("N: <line>"), then a blank, a "(…)" footer, and a standalone
458
+ // "</content>". The body is a *contiguous run* of /^N: / lines — so we stop at
459
+ // the first non-numbered line. Critically we do NOT search for a closing
460
+ // "</content>" substring: a file line that literally contains "</content>"
461
+ // is rendered as "N: </content>" (a body line), and a raw indexOf would
462
+ // truncate the read there.
463
+ let rest = output.slice(contentHeaderIdx + "<content>".length);
464
+ if (rest.startsWith("\n"))
465
+ rest = rest.slice(1);
466
+ const raw = [];
467
+ for (const line of rest.split("\n")) {
468
+ const m = /^(\d+):[ \t]?(.*)$/.exec(line);
469
+ if (!m)
470
+ break; // blank / "(footer)" / "</content>" → end of body run
471
+ // Strip only the leading "N: " prefix; a line that itself begins with
472
+ // digits+colon keeps its content (we remove just the first match). Blank
473
+ // file lines render as "N: " and are preserved (capture group is "").
474
+ raw.push(m[2]);
475
+ }
476
+ // Envelope confirmed but no numbered body → empty file. Return "" rather
477
+ // than the envelope (the envelope is exactly what Cursor echoes into writes).
478
+ return raw.join("\n");
479
+ }
414
480
  /**
415
481
  * Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
416
482
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
417
483
  * server accepts (verified against agent.v1 wire captures).
418
484
  */
419
- export function buildTypedExecResult(resultField, output, error) {
485
+ export function buildTypedExecResult(resultField, output, error, toolName) {
420
486
  switch (resultField) {
421
487
  case "read_result":
422
488
  if (error)
423
489
  return { error: { path: "", error } };
490
+ // Strip opencode's <path>/<content> envelope so Cursor's model receives
491
+ // raw file content and can't echo the wrapper into subsequent writes.
492
+ // reads route here only for native read_args; most arrive via mcp_result.
493
+ const content = unwrapReadOutput(output);
424
494
  return {
425
495
  success: {
426
496
  path: extractPathTag(output) ?? "",
427
- content: output,
428
- total_lines: countLines(output),
497
+ content,
498
+ total_lines: countLines(content),
429
499
  },
430
500
  };
431
501
  case "grep_result": {
@@ -492,9 +562,15 @@ export function buildTypedExecResult(resultField, output, error) {
492
562
  case "mcp_result":
493
563
  if (error)
494
564
  return { error: { error } };
565
+ // opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
566
+ // a read call returns through mcp_result. Scope the unwrap to toolName
567
+ // "read" so a non-read MCP tool whose output merely contains a
568
+ // "<content>"-like block is never rewritten.
495
569
  return {
496
570
  success: {
497
- content: [{ text: { text: output } }],
571
+ content: [
572
+ { text: { text: toolName === "read" ? unwrapReadOutput(output) : output } },
573
+ ],
498
574
  is_error: false,
499
575
  },
500
576
  };
package/dist/session.d.ts CHANGED
@@ -14,6 +14,11 @@ export type Frame = {
14
14
  export type PendingExec = {
15
15
  /** ExecClientMessage result field to reply with (matches the request variant). */
16
16
  resultField: string;
17
+ /**
18
+ * Resolved opencode tool name (read/write/grep/…). Used on continuation so
19
+ * mcp_result can unwrap read envelopes even if the prompt omits toolName.
20
+ */
21
+ toolName?: string;
17
22
  };
18
23
  export type CursorSession = {
19
24
  /**
@@ -58,7 +63,7 @@ export declare class SessionManager {
58
63
  constructor(idleTimeoutMs?: number);
59
64
  touch(session: CursorSession): void;
60
65
  /** Register that `session` is awaiting a result for `execId`. */
61
- registerPending(execId: number, session: CursorSession, resultField: string): void;
66
+ registerPending(execId: number, session: CursorSession, resultField: string, toolName?: string): void;
62
67
  /** The pending exec info for an id on a specific session, if still awaiting it. */
63
68
  pendingFor(sessionId: string, execId: number): PendingExec | undefined;
64
69
  /** Find the live session awaiting one of the given exec ids. */
package/dist/session.js CHANGED
@@ -1,4 +1,4 @@
1
- import { trace } from "./transport/connect.js";
1
+ import { trace } from "./debug.js";
2
2
  export class SessionManager {
3
3
  // Composite key `${sessionId}:${execId}` → owning session. Composite keying
4
4
  // means two Run streams that both register an execId of 1 (Cursor resets
@@ -12,8 +12,8 @@ export class SessionManager {
12
12
  session.expiresAt = Date.now() + this.idleTimeoutMs;
13
13
  }
14
14
  /** Register that `session` is awaiting a result for `execId`. */
15
- registerPending(execId, session, resultField) {
16
- session.pending.set(execId, { resultField });
15
+ registerPending(execId, session, resultField, toolName) {
16
+ session.pending.set(execId, { resultField, toolName });
17
17
  this.byExecId.set(this.key(session.sessionId, execId), session);
18
18
  this.touch(session);
19
19
  }
@@ -1,4 +1,3 @@
1
- export declare function trace(msg: string): void;
2
1
  export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
3
2
  export declare function unaryAvailableModels(token: string, options?: {
4
3
  apiBaseURL?: string;
@@ -3,32 +3,12 @@ import { encodeFrame, streamFrames } from "../protocol/framing.js";
3
3
  import { createCursorChecksumHeader } from "../protocol/checksum.js";
4
4
  import { getDeviceIds } from "../protocol/device-id.js";
5
5
  import { resolveClientVersion } from "../protocol/client-version.js";
6
+ import { trace } from "../debug.js";
6
7
  import http2 from "node:http2";
7
- import fs from "node:fs";
8
8
  const API_BASE = `https://${CURSOR_API_HOST}`;
9
9
  function resolveApiBaseURL(options) {
10
10
  return new URL(options.apiBaseURL ?? options.baseURL ?? API_BASE).origin;
11
11
  }
12
- // Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
13
- // Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
14
- // Truncated once per process. Captures h2 response status, parsed frames, and
15
- // stream errors. Tokens / checksums are redacted in header dumps.
16
- const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
17
- process.env.CURSOR_PROVIDER_DEBUG === "true";
18
- const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
19
- let _traceInitialized = false;
20
- export function trace(msg) {
21
- if (!DEBUG_ENABLED)
22
- return;
23
- try {
24
- if (!_traceInitialized) {
25
- _traceInitialized = true;
26
- fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
27
- }
28
- fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
29
- }
30
- catch { /* ignore */ }
31
- }
32
12
  trace("connect.ts module loaded");
33
13
  export function buildBaseHeaders(token, clientVersion, extra) {
34
14
  const { machineId, macMachineId } = getDeviceIds();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.1.4",
3
+ "version": "0.1.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",
@@ -54,12 +54,12 @@
54
54
  "protobufjs": "^7.4.0"
55
55
  },
56
56
  "peerDependencies": {
57
- "@opencode-ai/plugin": "~1.17.13"
57
+ "@opencode-ai/plugin": "^1.17.13"
58
58
  },
59
59
  "devDependencies": {
60
- "@opencode-ai/plugin": "~1.17.13",
60
+ "@opencode-ai/plugin": "^1.17.13",
61
61
  "@tsconfig/node22": "^22.0.1",
62
62
  "@types/node": "^22.15.3",
63
63
  "typescript": "^5.8.3"
64
64
  }
65
- }
65
+ }