cursor-opencode-provider 0.7.3 → 0.7.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
@@ -293,6 +293,8 @@ OpenCode
293
293
 
294
294
  The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, preferring OpenCode `todowrite` / `todoread` over Cursor TodoWrite when those tools are advertised without inviting catalog-comparison narration, and preferring `edit` / `write` (or `apply_patch`, when the host advertises that instead) over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
295
295
 
296
+ Cursor keeps OpenCode `skill` and configured MCP tools off its native top-level function list on **both** OpenCode 1.x and OpenCode 2.0 — they appear as MCP descriptors and are reached through GetDynamicTools / CallDynamicTool. When the host advertises `skill` and/or tools of MCP servers configured in `opencode.json`, the provider names those routes in system guidance. When the host permits `skill` this turn and the user message names a discovered skill id, matches its description, or asks which skills are available, it also appends a Mid-Conversation reminder with those skill ids, so models prefer `skill` / MCP over Grep/Shell (and do not narrate that those tools are unavailable). OpenCode 2 still moves MCP servers that did not set `"codemode": true` onto the direct AI SDK catalog first; without that placement they never reach Cursor's MCP ads at all (see [OpenCode 2 MCP tools](docs/opencode-2.md#mcp-tools)).
297
+
296
298
  Native Cursor todo display updates (`update_todos_tool_call` / `read_todos_tool_call`) are mirrored into `todowrite` / `todoread` when those host tools are advertised. OpenCode 2 dropped the host builtins; set `CURSOR_OPENCODE2_TODOS=1` (or `true`) to register the same canonical names when the catalog omits them (in-memory per session, cleared on `session.deleted` and process restart). With the flag unset, the plugin registers no todo tools. Enabled plugin tools are **direct** catalog tools (`options.codemode: false` plus an `output` schema), not Code Mode helpers. The provider retains the last known full list per OpenCode session — refreshed by bridged writes, direct host `todowrite` calls, and `todoread` results, surviving Run close, checkpoint resume, and later turns — so `merge: true` updates that omit Cursor's completed list still become a host replace-all snapshot (matched by id, then by content across id spaces). This snapshot is in-memory only and never enters the prompt, RequestContext, or persisted restart state, so Cursor's prompt-cache prefix is unaffected; a provider process restart clears it until the next write/read (intentional — see `tasks/lessons.md`).
297
299
 
298
300
  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).
@@ -1,10 +1,16 @@
1
1
  import { type OpencodeToolDef } from "../protocol/tools.js";
2
+ import { type OpencodeJson } from "./rules.js";
2
3
  export type BuildRequestContextInput = {
3
4
  workspaceRoot: string;
4
5
  tools?: OpencodeToolDef[];
5
6
  providerIdentifier?: string;
6
7
  /** When set, skills/subagents/plugins are epoch-held for this conversation. */
7
8
  conversationId?: string;
9
+ /**
10
+ * Preloaded merged `opencode.json` from the same Run (e.g. for interaction
11
+ * guidance MCP server ids). Skips a second `loadMergedConfig` disk read.
12
+ */
13
+ mergedConfig?: OpencodeJson;
8
14
  };
9
15
  export declare const DYNAMIC_REQUEST_CONTEXT_KEYS: readonly ["tools", "agent_skills", "custom_subagents", "mcp_file_system_options", "mcp_meta_tool_options", "web_search_enabled", "web_fetch_enabled", "agent_skills_info_complete", "custom_subagents_info_complete", "mcp_file_system_info_complete", "mcp_info_complete", "hooks_additional_context"];
10
16
  export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[number];
@@ -67,7 +67,7 @@ function buildAdvertisedSubagentCatalog(hostSubagents, discoveredAgents) {
67
67
  */
68
68
  export async function buildRequestContext(input) {
69
69
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
70
- const { rules, config, worktree } = await collectRules(workspaceRoot);
70
+ const { rules, config, worktree } = await collectRules(workspaceRoot, input.mergedConfig);
71
71
  const [dynamic, git, layout] = await Promise.all([
72
72
  buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config),
73
73
  collectGit(workspaceRoot),
@@ -176,7 +176,9 @@ export async function buildDynamicRequestContext(input) {
176
176
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
177
177
  const [worktree, config] = await Promise.all([
178
178
  findGitWorktree(workspaceRoot),
179
- loadMergedConfig(workspaceRoot),
179
+ input.mergedConfig
180
+ ? Promise.resolve(input.mergedConfig)
181
+ : loadMergedConfig(workspaceRoot),
180
182
  ]);
181
183
  return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config);
182
184
  }
@@ -0,0 +1,43 @@
1
+ export type AgentSkillLike = {
2
+ /** OpenCode skill id (frontmatter `name`, else directory name). */
3
+ id?: unknown;
4
+ full_path?: unknown;
5
+ description?: unknown;
6
+ content?: unknown;
7
+ };
8
+ /**
9
+ * Configured MCP servers that own at least one advertised tool. Uses the same
10
+ * server resolution as the RequestContext descriptors, so guidance never names
11
+ * a server Cursor was not given. `toolNames` are OpenCode ids (alias
12
+ * `sourceName` when present).
13
+ */
14
+ export declare function listAdvertisedMcpServers(toolNames: Iterable<string>, knownMcpServers?: Iterable<string>): string[];
15
+ /** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
16
+ export declare function skillNameFromAgentSkill(skill: AgentSkillLike): string | undefined;
17
+ /**
18
+ * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
19
+ * them over Grep/Shell when skills or project rules apply. Concrete skill ids
20
+ * are deliberately absent: this line is part of the frozen baseline system
21
+ * context, and per-turn ids go through `buildSkillCatalogNudge` instead.
22
+ */
23
+ export declare function buildDynamicCatalogRoutingInstruction(options: {
24
+ toolNames: Iterable<string>;
25
+ knownMcpServers?: Iterable<string>;
26
+ }): string | undefined;
27
+ /**
28
+ * Mid-Conversation nudge after RequestContext skills are known. Prefer skills
29
+ * that match the user turn; otherwise stay quiet unless the user asked about
30
+ * skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
31
+ * `hasSkillTool` must reflect what the host permits this turn, not the
32
+ * epoch-held advertisement.
33
+ */
34
+ export declare function buildSkillCatalogNudge(options: {
35
+ hasSkillTool: boolean;
36
+ skills: readonly AgentSkillLike[];
37
+ userText?: string;
38
+ /**
39
+ * Host skill parameter key from the advertised schema.
40
+ * OpenCode 1.x: `name`. OpenCode 2.0: `id`.
41
+ */
42
+ skillArgKey?: "name" | "id";
43
+ }): string | undefined;
@@ -0,0 +1,183 @@
1
+ import { resolveToolServerIdentity } from "../protocol/tools.js";
2
+ /**
3
+ * Issue #29: Cursor keeps OpenCode `skill` and MCP tools off its native
4
+ * top-level function list. Both OpenCode 1.x and OpenCode 2.0 reach the model
5
+ * as MCP-shaped RequestContext ads, then GetDynamicTools / CallDynamicTool
6
+ * (exec `mcp_args`). This module builds the shared routing guidance and the
7
+ * skill-aware Mid-Conversation nudge used on both hosts.
8
+ *
9
+ * OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
10
+ * and enter the AI SDK catalog; without that step they never reach this
11
+ * advertisement path at all.
12
+ */
13
+ const MAX_NAMED_SKILLS = 8;
14
+ const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
15
+ /** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
16
+ const DEFAULT_TOOL_SERVER = "opencode";
17
+ /** Common words that carry no skill-matching signal. */
18
+ const STOPWORDS = new Set([
19
+ "about", "all", "and", "any", "are", "but", "can", "for", "from", "has", "have",
20
+ "how", "into", "its", "not", "one", "only", "that", "the", "their", "them",
21
+ "then", "there", "these", "this", "use", "used", "uses", "using", "via", "was",
22
+ "what", "when", "which", "who", "why", "will", "with", "you", "your",
23
+ // Host/MCP instruction boilerplate — otherwise a server-instruction dump
24
+ // matches every firecrawl/search skill on "search"/"tools"/"server".
25
+ "tool", "tools", "server", "servers", "call", "calls", "fetch", "search",
26
+ "documentation", "library", "framework", "query", "please", "through",
27
+ ]);
28
+ /** An explicit question/request about skills, not incidental prose ("has the skills to"). */
29
+ const SKILL_INQUIRY = /\b(?:which|what|list|show|available|any|use|using|call|invoke|load|run)\b[^.?!\n]{0,40}\bskills?\b|\bskills?\b[^.?!\n]{0,40}\b(?:available|installed|loaded|exist|do you have|can you)\b/i;
30
+ /** Description-only hits above this look like instruction-dump false positives. */
31
+ const MAX_DESCRIPTION_ONLY_MATCHES = 2;
32
+ /**
33
+ * Match against the live user utterance only. Mid-conversation / MCP instruction
34
+ * injections and prior reminders must not re-trigger skill dumps.
35
+ */
36
+ function skillMatchCorpus(userText) {
37
+ return userText
38
+ .replace(/<system_reminder\b[^>]*>[\s\S]*?<\/system_reminder>/gi, " ")
39
+ .replace(/<system-update\b[^>]*>[\s\S]*?<\/system-update>/gi, " ")
40
+ .replace(/New MCP server instructions[\s\S]*?(?=\n[A-Z]|\n\n|$)/gi, " ");
41
+ }
42
+ function listWithOverflow(items, limit) {
43
+ const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
44
+ const hidden = items.length - limit;
45
+ return hidden > 0 ? `${shown} (+${hidden} more)` : shown;
46
+ }
47
+ /**
48
+ * Configured MCP servers that own at least one advertised tool. Uses the same
49
+ * server resolution as the RequestContext descriptors, so guidance never names
50
+ * a server Cursor was not given. `toolNames` are OpenCode ids (alias
51
+ * `sourceName` when present).
52
+ */
53
+ export function listAdvertisedMcpServers(toolNames, knownMcpServers = []) {
54
+ const known = [...knownMcpServers];
55
+ if (known.length === 0)
56
+ return [];
57
+ const servers = new Set();
58
+ for (const name of toolNames) {
59
+ const { server } = resolveToolServerIdentity(name, DEFAULT_TOOL_SERVER, known);
60
+ if (server !== DEFAULT_TOOL_SERVER)
61
+ servers.add(server);
62
+ }
63
+ return [...servers];
64
+ }
65
+ /** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
66
+ export function skillNameFromAgentSkill(skill) {
67
+ const id = typeof skill.id === "string" ? skill.id.trim() : "";
68
+ if (id)
69
+ return id;
70
+ const fullPath = typeof skill.full_path === "string" ? skill.full_path.trim() : "";
71
+ const parts = fullPath.split(/[\\/]+/).filter(Boolean);
72
+ if (parts.length < 2 || parts.at(-1).toLowerCase() !== "skill.md")
73
+ return undefined;
74
+ return parts.at(-2);
75
+ }
76
+ /**
77
+ * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
78
+ * them over Grep/Shell when skills or project rules apply. Concrete skill ids
79
+ * are deliberately absent: this line is part of the frozen baseline system
80
+ * context, and per-turn ids go through `buildSkillCatalogNudge` instead.
81
+ */
82
+ export function buildDynamicCatalogRoutingInstruction(options) {
83
+ const names = [...options.toolNames];
84
+ const hasSkill = names.includes("skill");
85
+ const mcpServers = listAdvertisedMcpServers(names, options.knownMcpServers);
86
+ if (!hasSkill && mcpServers.length === 0)
87
+ return undefined;
88
+ const extras = [];
89
+ if (hasSkill)
90
+ extras.push("`skill`");
91
+ if (mcpServers.length > 0) {
92
+ extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
93
+ }
94
+ return (`- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
95
+ "are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
96
+ "When a skill matches or project rules name an MCP server, discover and call those tools that way " +
97
+ "before Grep/Shell fallbacks. Do not narrate that they are unavailable.");
98
+ }
99
+ function wordTokens(text) {
100
+ return new Set(text
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/)
103
+ .filter((token) => token.length >= 3 && !STOPWORDS.has(token)));
104
+ }
105
+ function descriptionMatchesUserText(description, userTokens) {
106
+ let hits = 0;
107
+ let distinctive = false;
108
+ for (const token of wordTokens(description)) {
109
+ if (!userTokens.has(token))
110
+ continue;
111
+ hits += 1;
112
+ if (token.length >= 8)
113
+ distinctive = true;
114
+ if (hits >= 2)
115
+ return true;
116
+ }
117
+ // Single distinctive token (library names, skill ids) is enough.
118
+ return distinctive;
119
+ }
120
+ function nameMatchesUserText(name, userText) {
121
+ const haystack = userText.toLowerCase();
122
+ const candidates = [name.toLowerCase(), ...name.toLowerCase().split(/[-_]+/).filter((part) => part.length >= 5)];
123
+ for (const candidate of [...new Set(candidates)]) {
124
+ const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ if (new RegExp(`(?<![a-z0-9_-])${escaped}(?![a-z0-9_-])`).test(haystack))
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ /**
131
+ * Mid-Conversation nudge after RequestContext skills are known. Prefer skills
132
+ * that match the user turn; otherwise stay quiet unless the user asked about
133
+ * skills explicitly (avoids dumping dozens of firecrawl-* ids every turn).
134
+ * `hasSkillTool` must reflect what the host permits this turn, not the
135
+ * epoch-held advertisement.
136
+ */
137
+ export function buildSkillCatalogNudge(options) {
138
+ if (!options.hasSkillTool || options.skills.length === 0)
139
+ return undefined;
140
+ const userText = skillMatchCorpus(options.userText ?? "");
141
+ const userTokens = wordTokens(userText);
142
+ const skillInquiry = SKILL_INQUIRY.test(userText);
143
+ const named = options.skills
144
+ .map((skill) => {
145
+ const name = skillNameFromAgentSkill(skill);
146
+ if (!name)
147
+ return undefined;
148
+ const description = typeof skill.description === "string" ? skill.description : "";
149
+ const nameHit = nameMatchesUserText(name, userText);
150
+ const descHit = !!description && descriptionMatchesUserText(description, userTokens);
151
+ return { name, nameHit, descHit };
152
+ })
153
+ .filter((row) => !!row);
154
+ if (named.length === 0)
155
+ return undefined;
156
+ const nameMatched = named.filter((row) => row.nameHit);
157
+ const descOnly = named.filter((row) => row.descHit && !row.nameHit);
158
+ // Mass description-only hits are almost always instruction-dump noise
159
+ // (MCP server blurbs sharing "search"/"tools" with firecrawl skills).
160
+ const descMatched = descOnly.length <= MAX_DESCRIPTION_ONLY_MATCHES ? descOnly : [];
161
+ const selected = nameMatched.length > 0
162
+ ? [...nameMatched, ...descMatched]
163
+ : skillInquiry
164
+ ? named
165
+ : descMatched;
166
+ if (selected.length === 0)
167
+ return undefined;
168
+ const ids = [...new Set(selected.map((row) => row.name))];
169
+ // Prefer the advertised schema key; fall back to naming both so a stale
170
+ // Mid-Conversation hint cannot contradict GetDynamicTools on either host.
171
+ const argKey = options.skillArgKey;
172
+ const callHint = argKey
173
+ ? `Call \`skill\` with \`{ "${argKey}": "<skill-id>" }\` (host schema key is \`${argKey}\`) `
174
+ : "Call `skill` with the skill identifier using the parameter key from GetDynamicTools " +
175
+ "(`name` on OpenCode 1.x, `id` on OpenCode 2.0) ";
176
+ return ("<system_reminder>\n" +
177
+ "OpenCode skills are invoked with the host `skill` tool through GetDynamicTools / CallDynamicTool " +
178
+ `(MCP server \`${DEFAULT_TOOL_SERVER}\`, tool \`skill\`), not by Grep/Read of SKILL.md. ` +
179
+ `Relevant skill id(s) this turn: ${listWithOverflow(ids, MAX_NAMED_SKILLS)}. ` +
180
+ callHint +
181
+ "before answering from memory or falling back to shell/search tools.\n" +
182
+ "</system_reminder>");
183
+ }
@@ -1,4 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, type BuildRequestContextInput, } from "./build.js";
2
+ export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, type AgentSkillLike, } from "./dynamic-catalog.js";
2
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
3
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
4
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
@@ -1,4 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, } from "./build.js";
2
+ export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, listAdvertisedMcpServers, skillNameFromAgentSkill, } from "./dynamic-catalog.js";
2
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
3
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
4
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
@@ -39,6 +39,11 @@ export type OverlayWire = {
39
39
  };
40
40
  /** Merge live discovery into the conversation overlay epoch. */
41
41
  export declare function holdCapabilityOverlay(conversationId: string, live: OverlayHold): OverlayWire;
42
+ /**
43
+ * Epoch-held skills with their OpenCode ids. The wire shape drops `id`
44
+ * (`AgentSkill` has no name field), but the host `skill` tool needs it.
45
+ */
46
+ export declare function getHeldOverlaySkills(conversationId: string): OverlaySkill[];
42
47
  export declare function clearOverlayHold(conversationId: string): void;
43
48
  export declare function transferOverlayHold(previousConversationId: string, nextConversationId: string): void;
44
49
  export declare function resetOverlayHoldsForTests(): void;
@@ -78,6 +78,13 @@ export function holdCapabilityOverlay(conversationId, live) {
78
78
  }
79
79
  return toWire(remember(conversationId, next));
80
80
  }
81
+ /**
82
+ * Epoch-held skills with their OpenCode ids. The wire shape drops `id`
83
+ * (`AgentSkill` has no name field), but the host `skill` tool needs it.
84
+ */
85
+ export function getHeldOverlaySkills(conversationId) {
86
+ return structuredClone(byConversationId.get(conversationId)?.skills ?? []);
87
+ }
81
88
  export function clearOverlayHold(conversationId) {
82
89
  byConversationId.delete(conversationId);
83
90
  }
@@ -17,8 +17,9 @@ export declare function fetchRemoteInstruction(url: string, timeoutMs?: number):
17
17
  export declare function loadMergedConfig(workspaceRoot: string): Promise<OpencodeJson>;
18
18
  /**
19
19
  * Collect OpenCode instruction files.
20
+ * `preloadedConfig` reuses a merged config already loaded on this Run.
20
21
  */
21
- export declare function collectRules(workspaceRoot: string): Promise<{
22
+ export declare function collectRules(workspaceRoot: string, preloadedConfig?: OpencodeJson): Promise<{
22
23
  rules: CollectedRule[];
23
24
  config: OpencodeJson;
24
25
  worktree: string;
@@ -178,12 +178,13 @@ export async function loadMergedConfig(workspaceRoot) {
178
178
  }
179
179
  /**
180
180
  * Collect OpenCode instruction files.
181
+ * `preloadedConfig` reuses a merged config already loaded on this Run.
181
182
  */
182
- export async function collectRules(workspaceRoot) {
183
+ export async function collectRules(workspaceRoot, preloadedConfig) {
183
184
  const worktree = await findGitWorktree(workspaceRoot);
184
185
  const rules = [];
185
186
  const seen = new Set();
186
- const config = await loadMergedConfig(workspaceRoot);
187
+ const config = preloadedConfig ?? await loadMergedConfig(workspaceRoot);
187
188
  const add = async (file) => {
188
189
  if (!file)
189
190
  return;
@@ -55,4 +55,4 @@ export declare function resolveContainedImagePath(target: string, allowedRoots:
55
55
  */
56
56
  export declare function executeCursorImageSave(args: {
57
57
  image_id?: unknown;
58
- }, ctx: ImageSaveToolContext): Promise<ImageSaveResult | string>;
58
+ }, ctx: ImageSaveToolContext): Promise<ImageSaveResult>;
@@ -106,18 +106,18 @@ function isInsideProject(target, worktree, directory) {
106
106
  export async function executeCursorImageSave(args, ctx) {
107
107
  const imageId = typeof args.image_id === "string" ? args.image_id : "";
108
108
  if (!imageId)
109
- return "No image id was provided, so there is nothing to save.";
109
+ throw new Error("No image id was provided, so there is nothing to save.");
110
110
  const image = takePendingCursorImage(imageId);
111
111
  if (!image) {
112
112
  // Expired, already committed, or never existed. Identical response for all
113
113
  // three: nothing here should confirm whether an id was ever valid.
114
- return "No pending Cursor image matches that id. It may have already been saved or expired.";
114
+ throw new Error("No pending Cursor image matches that id. It may have already been saved or expired.");
115
115
  }
116
116
  const workspace = ctx.worktree || ctx.directory;
117
117
  const contained = resolveContainedImagePath(image.path, [image.projectDir, workspace].filter((root) => !!root));
118
118
  if ("error" in contained) {
119
119
  trace(`image save: refused path=${JSON.stringify(image.path)} reason=${contained.error}`);
120
- return `Refusing to save the generated image: ${contained.error}.`;
120
+ throw new Error(`Refusing to save the generated image: ${contained.error}.`);
121
121
  }
122
122
  // Mirror what OpenCode's own `write` tool does, in the same order: an
123
123
  // external-directory gate for targets outside the project, then the `edit`
@@ -25,7 +25,12 @@ export const MAX_STAGED_IMAGE_BYTES = 50 * 1024 * 1024;
25
25
  export const STAGED_IMAGE_TTL_MS = 10 * 60_000;
26
26
  /** Bounds memory if a turn generates several images and none are committed. */
27
27
  const MAX_PENDING = 8;
28
- const pending = new Map();
28
+ // Some hosts load the model and the image-save subpath in separate module
29
+ // graphs. Keep the one-use handles in the process registry so both graphs see
30
+ // the same staged bytes. The UUID handle remains the only tool input.
31
+ const PENDING_IMAGES = Symbol.for("cursor-opencode-provider.pending-images");
32
+ const globals = globalThis;
33
+ const pending = globals[PENDING_IMAGES] ??= new Map();
29
34
  export class StagedImageTooLargeError extends Error {
30
35
  constructor(bytes) {
31
36
  super(`Generated image is ${bytes} bytes, above the ${MAX_STAGED_IMAGE_BYTES} byte limit`);
@@ -133,6 +133,8 @@ type ExtractedToolResult = {
133
133
  * are ignored — they are conversation history, not replies for a held-open Run.
134
134
  */
135
135
  export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
136
+ /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
137
+ export declare function hasApprovedUncorrelatedPlanStageResult(prompt: LanguageModelV3CallOptions["prompt"]): boolean;
136
138
  /**
137
139
  * Checkpointed Runs do not resend the system prompt. Keep the workspace root
138
140
  * on the live user message, and require absolute `path` arguments when that is
@@ -147,7 +149,9 @@ export declare function groundCheckpointTurnText(userText: string, checkpoint: b
147
149
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
148
150
  * compaction keeps its dedicated summary prompt unchanged.
149
151
  */
150
- export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string): string | undefined;
152
+ export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string, options?: {
153
+ knownMcpServers?: Iterable<string>;
154
+ }): string | undefined;
151
155
  /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
152
156
  export declare function estimateTokens(chars: number): number;
153
157
  /** Preserve exact request-local Cursor counters as diagnostics. */
@@ -32,6 +32,9 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
32
32
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
33
33
  import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
34
34
  import { getOrBuildRequestContext } from "./context/frozen.js";
35
+ import { getHeldOverlaySkills } from "./context/overlay.js";
36
+ import { loadMergedConfig } from "./context/rules.js";
37
+ import { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, } from "./context/dynamic-catalog.js";
35
38
  import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
36
39
  import { workspaceRootFromRequestContext } from "./context/env.js";
37
40
  import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
@@ -507,6 +510,12 @@ async function doStreamImpl(modelId, options, callOptions) {
507
510
  // false "orphaned tool results" errors after Cursor turn_ended and OpenCode
508
511
  // started the next step with old tools still in the prompt body.
509
512
  const trailingToolResults = extractTrailingToolResults(prompt);
513
+ // A host may submit an already rendered plan through its canonical stage
514
+ // tool after the Cursor Run has ended. The next call carries that tool result
515
+ // without a Cursor exec id; reconcile the approved mode before a fresh Run.
516
+ if (hasApprovedUncorrelatedPlanStageResult(prompt)) {
517
+ setActiveCursorMode(opencodeSessionKey(callOptions), "agent");
518
+ }
510
519
  let session = findContinuationSession(trailingToolResults);
511
520
  if (session) {
512
521
  // Write pending results onto the held-open Run. A dead stream closes the
@@ -801,15 +810,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
801
810
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
802
811
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
803
812
  // projects, so its static option is only a last-resort fallback. Prefer the
804
- // per-request `x-opencode-directory` header, then the session mark recorded
805
- // from `session.hook("context")` via `getSessionDirectory`.
813
+ // per-request `x-opencode-directory` header (set by the 2.0 plugin's
814
+ // `session.hook("model.request")`), then the session mark recorded from the
815
+ // session hooks via `getSessionDirectory`.
806
816
  const workspaceRoot = resolveSessionWorkspaceRoot({
807
817
  sessionKey,
808
818
  headers: callOptions.headers,
809
819
  workspaceRoot: options.workspaceRoot,
810
820
  });
811
821
  const baseSystemPrompt = extractSystemPrompt(prompt);
812
- const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
822
+ // One merged-config load per Run: guidance MCP ids and RequestContext
823
+ // descriptors must agree, and warm turns must not pay for a second disk read.
824
+ const mergedConfig = isCompaction ? undefined : await loadMergedConfig(workspaceRoot);
825
+ const knownMcpServers = Object.keys(mergedConfig?.mcp ?? {});
826
+ const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot, {
827
+ knownMcpServers,
828
+ });
813
829
  // Prompt-identity diagnostics are filled after Context Epoch admission below
814
830
  // (baseline hash is the epoch baseline, not a per-turn host hash).
815
831
  let frozenSystemPromptHash;
@@ -864,6 +880,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
864
880
  let userText = recovery?.kind === "rebase"
865
881
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
866
882
  : (extractUserText(lastUser) || ".");
883
+ // Skill matching must see the live user utterance only — not Mid-Conversation
884
+ // MCP/system injections appended below (those false-trigger firecrawl dumps).
885
+ const skillMatchText = userText;
867
886
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
868
887
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
869
888
  const startedWithCheckpoint = !!conversationState;
@@ -987,7 +1006,22 @@ async function startSession(modelId, token, callOptions, options, startOptions)
987
1006
  });
988
1007
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
989
1008
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
990
- const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
1009
+ const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
1010
+ // Issue #29: after skills are in RequestContext, nudge matched skill ids onto
1011
+ // the Mid-Conversation channel so models CallDynamicTool(`skill`) instead of
1012
+ // Grep/Read SKILL.md. Shared by OpenCode 1.x and 2.0 catalogs. Gate on the
1013
+ // host-permitted set, not the epoch-held advertisement: a turn that withheld
1014
+ // `skill` would refuse the call this nudge asks for.
1015
+ if (!isCompaction && !lifecycle) {
1016
+ const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1017
+ const skillNudge = buildSkillCatalogNudge({
1018
+ hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
1019
+ skills: getHeldOverlaySkills(conversationId),
1020
+ userText: skillMatchText,
1021
+ skillArgKey: skillDialect.skillArgKey,
1022
+ });
1023
+ userText = appendMidConversationMessage(userText, skillNudge);
1024
+ }
991
1025
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
992
1026
  ? requestContext.custom_subagents
993
1027
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -1097,7 +1131,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1097
1131
  }
1098
1132
  const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1099
1133
  trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
1100
- `tools=[${tools.map((t) => t.name).join(",")}]`);
1134
+ `skillArgKey=${hostToolDialect.skillArgKey} tools=[${tools.map((t) => t.name).join(",")}]`);
1101
1135
  const session = {
1102
1136
  sessionId: crypto.randomUUID(),
1103
1137
  conversationId,
@@ -1836,6 +1870,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
1836
1870
  });
1837
1871
  }
1838
1872
  }
1873
+ // A directly called advertised plan-stage tool can own the same review
1874
+ // gate as a bridged CreatePlan. Its successful result means execution was
1875
+ // approved, so the next Run must carry agent-mode guidance.
1876
+ if (!pending.bridged
1877
+ && pending.toolName === CURSOR_PLAN_STAGE_TOOL
1878
+ && r.error === undefined) {
1879
+ setActiveCursorMode(session.openCodeSessionId, "agent");
1880
+ }
1839
1881
  // A host `todoread` result is the authoritative list. Refresh the mirrored
1840
1882
  // snapshot (direct reads and bridged native reads alike) so later merges
1841
1883
  // apply onto host truth, not a stale write.
@@ -2864,7 +2906,7 @@ export async function pump(session, controller, ids, abortSignal) {
2864
2906
  imageId = stageCursorImage({
2865
2907
  path: target,
2866
2908
  projectDir,
2867
- mime: imageMimeForPath(target),
2909
+ mime: imageMimeForPath(target, binaryWrite.data),
2868
2910
  data: binaryWrite.data,
2869
2911
  sessionId: session.openCodeSessionId,
2870
2912
  });
@@ -3299,6 +3341,25 @@ export function extractTrailingToolResults(prompt) {
3299
3341
  return [];
3300
3342
  return extractToolResults(prompt.slice(i + 1));
3301
3343
  }
3344
+ /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
3345
+ export function hasApprovedUncorrelatedPlanStageResult(prompt) {
3346
+ if (prompt.length === 0 || prompt[prompt.length - 1].role !== "tool")
3347
+ return false;
3348
+ for (let i = prompt.length - 1; i >= 0 && prompt[i].role === "tool"; i--) {
3349
+ const message = prompt[i];
3350
+ if (!Array.isArray(message.content))
3351
+ continue;
3352
+ for (const part of message.content) {
3353
+ if (part.type !== "tool-result" || part.toolName !== CURSOR_PLAN_STAGE_TOOL)
3354
+ continue;
3355
+ if (parseExecIdFromToolCallId(part.toolCallId))
3356
+ continue;
3357
+ const result = toolResultOutputToText(part.output);
3358
+ return !result.isError;
3359
+ }
3360
+ }
3361
+ return false;
3362
+ }
3302
3363
  function toolResultOutputToText(output) {
3303
3364
  if (output == null)
3304
3365
  return { text: "", isError: false };
@@ -3351,7 +3412,7 @@ export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, to
3351
3412
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
3352
3413
  * compaction keeps its dedicated summary prompt unchanged.
3353
3414
  */
3354
- export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
3415
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot, options = {}) {
3355
3416
  if (isCompaction)
3356
3417
  return undefined;
3357
3418
  const names = new Set(tools.map((tool) => tool.name));
@@ -3406,7 +3467,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3406
3467
  instructions.push(shell
3407
3468
  ? `- OpenCode \`execute\` is Code Mode JavaScript (\`code\`); it is not a shell. For OS commands, call OpenCode ${shell}. Do not pass \`command\` to \`execute\`.`
3408
3469
  : "- OpenCode `execute` is Code Mode JavaScript (`code`); it is not a shell. Do not pass `command` to `execute`.");
3409
- instructions.push("- When the host's Code Mode catalog lists additional tools, including MCP server tools, call them inside `execute` through `tools`. Use only the exact paths and signatures in that catalog or returned by its `search` function. Call `execute` with `{ code }` to run them; do not request a Code Mode tool as a direct OpenCode tool call.");
3470
+ instructions.push("- Call tools named in the direct list by their own names, even when a server instruction says to reach them through `execute`. Use `execute` only for tools that appear in the host Code Mode catalog and are absent from that list. Use the exact paths and signatures from that catalog or its `search` function, and call `execute` with `{ code }`.");
3410
3471
  }
3411
3472
  if (names.has("task") || names.has("subagent")) {
3412
3473
  const target = names.has("task") ? "`task`" : "`subagent`";
@@ -3419,6 +3480,16 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3419
3480
  instructions.push("- Host `scout` is available for external documentation and dependency-source research. Use Cursor `cursor-guide` for that use case; local repository discovery still uses `bugbot`/`explore`.");
3420
3481
  }
3421
3482
  }
3483
+ // Issue #29: skill/MCP stay off Cursor's native top-level list on both
3484
+ // OpenCode 1.x and 2.0 — route them through the dynamic catalog instead.
3485
+ {
3486
+ const dynamicCatalog = buildDynamicCatalogRoutingInstruction({
3487
+ toolNames: tools.map((tool) => tool.sourceName ?? tool.name),
3488
+ knownMcpServers: options.knownMcpServers,
3489
+ });
3490
+ if (dynamicCatalog)
3491
+ instructions.push(dynamicCatalog);
3492
+ }
3422
3493
  if (names.has("write")) {
3423
3494
  instructions.push(names.has("edit")
3424
3495
  ? "- 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."
@@ -0,0 +1,31 @@
1
+ import type { ToolDraft } from "./types.js";
2
+ /**
3
+ * OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
4
+ * config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
5
+ * `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
6
+ * the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
7
+ * a tool to Cursor only when it is in that catalog.
8
+ *
9
+ * That AI SDK placement is necessary but not sufficient for Cursor models:
10
+ * non-native tools are still invoked through GetDynamicTools / CallDynamicTool
11
+ * (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
12
+ * puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
13
+ *
14
+ * `config.codemode` is also the remote-connection switch: while it is not
15
+ * false, OpenCode appends `?codemode=false` so servers that bundle their own
16
+ * Code Mode return individual tools (`packages/core/src/mcp/client.ts`).
17
+ * This module therefore leaves server config alone and clears the tool option
18
+ * instead. An explicit server `codemode: true` stays in Code Mode. OpenCode's
19
+ * own namespaced tools (the `opencode` namespace) are not MCP servers and are
20
+ * left alone.
21
+ *
22
+ * The tool registry is location-scoped, so this placement is shared by every
23
+ * provider in the process. `"codemode": true` on a server is the per-server
24
+ * way to keep that server inside `execute`.
25
+ */
26
+ export declare function mcpServerNamespace(server: string): string;
27
+ export declare function rememberDirectMcpNamespaces(target: Set<string>, servers: readonly (readonly [string, {
28
+ readonly codemode?: boolean;
29
+ }])[]): void;
30
+ /** Move tools from the recorded MCP namespaces onto the direct catalog. */
31
+ export declare function exposeDirectMcpTools(editor: ToolDraft, namespaces: ReadonlySet<string>): void;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
3
+ * config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
4
+ * `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
5
+ * the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
6
+ * a tool to Cursor only when it is in that catalog.
7
+ *
8
+ * That AI SDK placement is necessary but not sufficient for Cursor models:
9
+ * non-native tools are still invoked through GetDynamicTools / CallDynamicTool
10
+ * (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
11
+ * puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
12
+ *
13
+ * `config.codemode` is also the remote-connection switch: while it is not
14
+ * false, OpenCode appends `?codemode=false` so servers that bundle their own
15
+ * Code Mode return individual tools (`packages/core/src/mcp/client.ts`).
16
+ * This module therefore leaves server config alone and clears the tool option
17
+ * instead. An explicit server `codemode: true` stays in Code Mode. OpenCode's
18
+ * own namespaced tools (the `opencode` namespace) are not MCP servers and are
19
+ * left alone.
20
+ *
21
+ * The tool registry is location-scoped, so this placement is shared by every
22
+ * provider in the process. `"codemode": true` on a server is the per-server
23
+ * way to keep that server inside `execute`.
24
+ */
25
+ export function mcpServerNamespace(server) {
26
+ return server.replace(/[^a-zA-Z0-9_-]/g, "_");
27
+ }
28
+ export function rememberDirectMcpNamespaces(target, servers) {
29
+ target.clear();
30
+ // The editor exposes normalized namespaces, not server ownership. Keep
31
+ // ambiguous namespaces in their host-selected placement: otherwise a server
32
+ // named `opencode` moves builtins too, or `my.docs` overrides an explicit
33
+ // Code Mode choice on `my_docs`. Exclusions must win regardless of order.
34
+ const excluded = new Set(["opencode"]);
35
+ for (const [name, config] of servers) {
36
+ const namespace = mcpServerNamespace(name);
37
+ if (config.codemode === true)
38
+ excluded.add(namespace);
39
+ else
40
+ target.add(namespace);
41
+ }
42
+ for (const namespace of excluded)
43
+ target.delete(namespace);
44
+ }
45
+ /** Move tools from the recorded MCP namespaces onto the direct catalog. */
46
+ export function exposeDirectMcpTools(editor, namespaces) {
47
+ if (namespaces.size === 0 || !editor.list || !editor.update)
48
+ return;
49
+ for (const tool of editor.list()) {
50
+ const namespace = tool.options?.namespace;
51
+ if (!namespace || !namespaces.has(namespace))
52
+ continue;
53
+ if (tool.options?.codemode === false)
54
+ continue;
55
+ editor.update(tool.id, (draft) => {
56
+ const options = {};
57
+ if (draft.options?.namespace !== undefined)
58
+ options.namespace = draft.options.namespace;
59
+ if (draft.options?.permission !== undefined)
60
+ options.permission = draft.options.permission;
61
+ options.codemode = false;
62
+ draft.options = options;
63
+ });
64
+ }
65
+ }
@@ -17,6 +17,13 @@ export type Registration = {
17
17
  readonly dispose: () => Promise<void>;
18
18
  };
19
19
  export type Hooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void) => Promise<Registration>;
20
+ export type ModelHookOptions = {
21
+ /** Limits the hook to one provider; the host skips events for any other. */
22
+ readonly providerID?: string;
23
+ };
24
+ export type ModelHooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void, options?: Spec[Name] extends {
25
+ readonly model: unknown;
26
+ } ? ModelHookOptions : never) => Promise<Registration>;
20
27
  export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>;
21
28
  export type ProviderInfo = {
22
29
  id: string;
@@ -223,6 +230,15 @@ export type ToolDraft = {
223
230
  list?(): readonly (ToolDefinition & {
224
231
  readonly id: string;
225
232
  })[];
233
+ /** Host ToolEditor.update — missing ids are ignored. */
234
+ update?(id: string, update: (tool: {
235
+ options?: {
236
+ namespace?: string;
237
+ permission?: string;
238
+ codemode?: boolean;
239
+ pinned?: boolean;
240
+ };
241
+ }) => void): void;
226
242
  };
227
243
  export type ToolHookBaseFields = {
228
244
  readonly tool: string;
@@ -295,11 +311,28 @@ export type SessionTitle = {
295
311
  options?: Record<string, unknown>;
296
312
  result?: string;
297
313
  };
314
+ /**
315
+ * Outbound request headers / route overrides for one model call.
316
+ * Headers reach the AI SDK as `callOptions.headers`.
317
+ */
318
+ export type SessionModelRequest = {
319
+ readonly sessionID: string;
320
+ readonly agent: string;
321
+ readonly model: {
322
+ providerID: string;
323
+ id: string;
324
+ variant?: string;
325
+ };
326
+ readonly kind: "primary" | "compaction" | "title" | "generate";
327
+ baseURL?: string;
328
+ headers: Record<string, string>;
329
+ };
298
330
  export type SessionHooks = {
299
331
  readonly context: SessionContext;
300
332
  readonly compaction: SessionCompaction;
301
333
  readonly generate: SessionGenerate;
302
334
  readonly title: SessionTitle;
335
+ readonly "model.request": SessionModelRequest;
303
336
  };
304
337
  /** Only the `location.directory` field we actually read. */
305
338
  export type SessionInfo = {
@@ -309,7 +342,7 @@ export type SessionInfo = {
309
342
  };
310
343
  };
311
344
  export type SessionDomain = {
312
- readonly hook: Hooks<SessionHooks>;
345
+ readonly hook: ModelHooks<SessionHooks>;
313
346
  readonly get: (input: {
314
347
  sessionID: string;
315
348
  }) => Promise<SessionInfo>;
@@ -368,6 +401,20 @@ export type WebSearchDomain = {
368
401
  readonly transform: Transform<WebSearchEditor>;
369
402
  readonly reload: () => Promise<void>;
370
403
  };
404
+ /**
405
+ * MCP config reader this plugin uses. Server `codemode` is observed and not
406
+ * written: writing it also changes the remote raw-tool URL. The host
407
+ * `MCPEditor` is a superset.
408
+ */
409
+ export type McpServerConfig = {
410
+ readonly codemode?: boolean;
411
+ };
412
+ export type McpEditor = {
413
+ list(): readonly (readonly [string, McpServerConfig])[];
414
+ };
415
+ export type McpDomain = {
416
+ readonly transform: Transform<McpEditor>;
417
+ };
371
418
  export type PluginLocation = {
372
419
  readonly directory: string;
373
420
  };
@@ -381,6 +428,7 @@ export type PluginContext = {
381
428
  readonly location?: PluginLocation;
382
429
  readonly shell?: ShellDomain;
383
430
  readonly websearch?: WebSearchDomain;
431
+ readonly mcp?: McpDomain;
384
432
  };
385
433
  export type Cleanup = () => Promise<void> | void;
386
434
  export type Plugin2 = {
@@ -8,11 +8,13 @@ import { fetchOpenCodeWebSearchText, parseExaWebSearchResults, } from "./web-too
8
8
  import { captureCursorShellResult, cursorShellEnvForCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
9
  import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2/catalog.js";
10
10
  import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
11
+ import { exposeDirectMcpTools, rememberDirectMcpNamespaces } from "./opencode2/mcp-direct.js";
11
12
  import { registerTodoTools } from "./opencode2/todo-tools.js";
12
13
  import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
13
14
  import { clearSessionTodos } from "./todo-store.js";
14
15
  import { markCompactionSession } from "./compaction-marker.js";
15
- import { markSessionDirectory } from "./session-directory.js";
16
+ import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
17
+ import { trace } from "./debug.js";
16
18
  import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
17
19
  import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
18
20
  import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
@@ -214,8 +216,8 @@ const plugin = {
214
216
  ...(token ? { accessToken: token } : {}),
215
217
  // Static fallback only. This hook fires once per model/package, not
216
218
  // per session, and 2.0 runs one daemon across many projects — the
217
- // real per-request directory comes from `x-opencode-directory` and
218
- // the session.context hook below via `getSessionDirectory`.
219
+ // real per-request directory comes from `x-opencode-directory`
220
+ // (set on `session.model.request`) and the session-directory mark.
219
221
  workspaceRoot,
220
222
  cacheDir,
221
223
  ...event.options,
@@ -250,12 +252,26 @@ const plugin = {
250
252
  });
251
253
  }));
252
254
  }
255
+ // Namespaces whose tools belong on the direct catalog. Filled by the MCP
256
+ // transform (config is not written) and read when tool transforms replay,
257
+ // including after a later MCP discovery reload.
258
+ const directMcpNamespaces = new Set();
259
+ if (ctx.mcp) {
260
+ await track(ctx.mcp.transform((editor) => {
261
+ rememberDirectMcpNamespaces(directMcpNamespaces, editor.list());
262
+ }));
263
+ }
253
264
  await track(ctx.tool.transform((draft) => {
254
265
  // OpenCode 2 dropped host todowrite/todoread. Off by default
255
266
  // (`CURSOR_OPENCODE2_TODOS=1`/`true` force-enables). When on, register
256
267
  // them as direct catalog tools (`codemode: false` + output schema) if
257
268
  // the host does not already own those names. When off, register none.
258
269
  registerTodoTools(draft);
270
+ // MCP tools default into Code Mode. Move every server that did not
271
+ // explicitly opt in onto the direct catalog so this provider can
272
+ // advertise them to Cursor (issue #29 still routes via CallDynamicTool).
273
+ // See `opencode2/mcp-direct.ts`.
274
+ exposeDirectMcpTools(draft, directMcpNamespaces);
259
275
  }));
260
276
  // ── Shell timeout wrapper ────────────────────────────────
261
277
  await track(ctx.tool.hook("execute.before", (event) => {
@@ -328,13 +344,12 @@ const plugin = {
328
344
  }
329
345
  const rememberSessionDirectory = async (sessionID) => {
330
346
  try {
331
- const info = (await ctx.session.get({ sessionID }));
332
- // OpenCode 2.0 stable exposes a flat `directory`; older shapes nest it
333
- // under `location.directory`. Prefer the flat field when both exist.
334
- markSessionDirectory(sessionID, info.directory ?? info.location?.directory);
347
+ const info = await ctx.session.get({ sessionID });
348
+ markSessionDirectory(sessionID, info.location.directory);
335
349
  }
336
- catch {
350
+ catch (error) {
337
351
  // Best effort — falls back to the static workspaceRoot above.
352
+ trace(`session directory: session.get failed sessionID=${sessionID}: ${String(error)}`);
338
353
  }
339
354
  };
340
355
  await track(ctx.session.hook("context", async (event) => {
@@ -357,6 +372,31 @@ const plugin = {
357
372
  }
358
373
  await rememberSessionDirectory(event.sessionID);
359
374
  }));
375
+ // The session mark lives in module state, and OpenCode re-evaluates a local
376
+ // plugin's module graph per Location, so the copy running the model may not
377
+ // hold it. The header travels with the request (AI SDK
378
+ // `callOptions.headers` → `resolveSessionWorkspaceRoot`) and never reaches
379
+ // Cursor. Only a successful lookup updates the mark; on failure keep the
380
+ // last known session directory ahead of this Location's static root.
381
+ // Scoped by the host to this provider: other providers' requests never
382
+ // reach the callback, so the header cannot leak to their endpoints.
383
+ await track(ctx.session.hook("model.request", async (event) => {
384
+ const current = await ctx.session
385
+ .get({ sessionID: event.sessionID })
386
+ .then((info) => info.location.directory)
387
+ .catch((error) => {
388
+ trace(`model.request: session.get failed sessionID=${event.sessionID}: ${String(error)}`);
389
+ return undefined;
390
+ });
391
+ markSessionDirectory(event.sessionID, current);
392
+ const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
393
+ if (!directory)
394
+ return;
395
+ event.headers = {
396
+ ...event.headers,
397
+ "x-opencode-directory": encodeURIComponent(directory),
398
+ };
399
+ }, { providerID: CURSOR_PROVIDER_ID }));
360
400
  await track(ctx.session.hook("compaction", async (event) => {
361
401
  markCompactionAndOptions(event, true);
362
402
  await rememberSessionDirectory(event.sessionID);
@@ -91,6 +91,12 @@ export declare const CURSOR_MODEL_COSTS: {
91
91
  readonly cache_read: 0.2;
92
92
  readonly cache_write: 2.5;
93
93
  };
94
+ readonly "claude-sonnet-5-5": {
95
+ readonly input: 2;
96
+ readonly output: 10;
97
+ readonly cache_read: 0.2;
98
+ readonly cache_write: 2.5;
99
+ };
94
100
  readonly "composer-2.5": {
95
101
  readonly input: 0.5;
96
102
  readonly output: 2.5;
@@ -141,6 +147,16 @@ export declare const CURSOR_MODEL_COSTS: {
141
147
  readonly output: 4.4;
142
148
  readonly cache_read: 0.26;
143
149
  };
150
+ readonly "glm-5.3": {
151
+ readonly input: 1.4;
152
+ readonly output: 4.4;
153
+ readonly cache_read: 0.26;
154
+ };
155
+ readonly "glm-5.3-flash": {
156
+ readonly input: 0.15;
157
+ readonly output: 0.5;
158
+ readonly cache_read: 0.029;
159
+ };
144
160
  readonly "gpt-5-mini": {
145
161
  readonly input: 0.25;
146
162
  readonly output: 2;
@@ -335,6 +351,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
335
351
  readonly maxContext: 200000;
336
352
  readonly maxContextForMaxMode: 1000000;
337
353
  };
354
+ readonly "claude-sonnet-5-5": {
355
+ readonly maxContext: 200000;
356
+ readonly maxContextForMaxMode: 1000000;
357
+ };
338
358
  readonly "composer-2.5": {
339
359
  readonly maxContext: 200000;
340
360
  };
@@ -369,6 +389,12 @@ export declare const CURSOR_MODEL_CONTEXTS: {
369
389
  readonly "glm-5.2": {
370
390
  readonly maxContext: 200000;
371
391
  };
392
+ readonly "glm-5.3": {
393
+ readonly maxContext: 1000000;
394
+ };
395
+ readonly "glm-5.3-flash": {
396
+ readonly maxContext: 1000000;
397
+ };
372
398
  readonly "gpt-5-mini": {
373
399
  readonly maxContext: 272000;
374
400
  };
@@ -469,6 +495,9 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
469
495
  readonly "claude-sonnet-5": {
470
496
  readonly supportsImages: true;
471
497
  };
498
+ readonly "claude-sonnet-5-5": {
499
+ readonly supportsImages: true;
500
+ };
472
501
  readonly "composer-2.5": {
473
502
  readonly supportsImages: true;
474
503
  };
@@ -496,6 +525,12 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
496
525
  readonly "glm-5.2": {
497
526
  readonly supportsImages: false;
498
527
  };
528
+ readonly "glm-5.3": {
529
+ readonly supportsImages: false;
530
+ };
531
+ readonly "glm-5.3-flash": {
532
+ readonly supportsImages: true;
533
+ };
499
534
  readonly "gpt-5-mini": {
500
535
  readonly supportsImages: true;
501
536
  };
@@ -91,6 +91,12 @@ export const CURSOR_MODEL_COSTS = {
91
91
  "cache_read": 0.2,
92
92
  "cache_write": 2.5
93
93
  },
94
+ "claude-sonnet-5-5": {
95
+ "input": 2,
96
+ "output": 10,
97
+ "cache_read": 0.2,
98
+ "cache_write": 2.5
99
+ },
94
100
  "composer-2.5": {
95
101
  "input": 0.5,
96
102
  "output": 2.5,
@@ -141,6 +147,16 @@ export const CURSOR_MODEL_COSTS = {
141
147
  "output": 4.4,
142
148
  "cache_read": 0.26
143
149
  },
150
+ "glm-5.3": {
151
+ "input": 1.4,
152
+ "output": 4.4,
153
+ "cache_read": 0.26
154
+ },
155
+ "glm-5.3-flash": {
156
+ "input": 0.15,
157
+ "output": 0.5,
158
+ "cache_read": 0.029
159
+ },
144
160
  "gpt-5-mini": {
145
161
  "input": 0.25,
146
162
  "output": 2,
@@ -335,6 +351,10 @@ export const CURSOR_MODEL_CONTEXTS = {
335
351
  "maxContext": 200000,
336
352
  "maxContextForMaxMode": 1000000
337
353
  },
354
+ "claude-sonnet-5-5": {
355
+ "maxContext": 200000,
356
+ "maxContextForMaxMode": 1000000
357
+ },
338
358
  "composer-2.5": {
339
359
  "maxContext": 200000
340
360
  },
@@ -369,6 +389,12 @@ export const CURSOR_MODEL_CONTEXTS = {
369
389
  "glm-5.2": {
370
390
  "maxContext": 200000
371
391
  },
392
+ "glm-5.3": {
393
+ "maxContext": 1000000
394
+ },
395
+ "glm-5.3-flash": {
396
+ "maxContext": 1000000
397
+ },
372
398
  "gpt-5-mini": {
373
399
  "maxContext": 272000
374
400
  },
@@ -469,6 +495,9 @@ export const CURSOR_MODEL_CAPABILITIES = {
469
495
  "claude-sonnet-5": {
470
496
  "supportsImages": true
471
497
  },
498
+ "claude-sonnet-5-5": {
499
+ "supportsImages": true
500
+ },
472
501
  "composer-2.5": {
473
502
  "supportsImages": true
474
503
  },
@@ -496,6 +525,12 @@ export const CURSOR_MODEL_CAPABILITIES = {
496
525
  "glm-5.2": {
497
526
  "supportsImages": false
498
527
  },
528
+ "glm-5.3": {
529
+ "supportsImages": false
530
+ },
531
+ "glm-5.3-flash": {
532
+ "supportsImages": true
533
+ },
499
534
  "gpt-5-mini": {
500
535
  "supportsImages": true
501
536
  },
@@ -30,8 +30,8 @@ export type DecodedGenerateImageQuery = {
30
30
  referenceImagePaths: string[];
31
31
  toolCallId: string;
32
32
  };
33
- /** Cursor names the target file; its extension is the only mime signal sent. */
34
- export declare function imageMimeForPath(filePath: string): string;
33
+ /** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
34
+ export declare function imageMimeForPath(filePath: string, data?: Uint8Array): string;
35
35
  /** Decode a `generate_image_request_query` body, or undefined when unusable. */
36
36
  export declare function decodeGenerateImageQuery(queryBytes: Uint8Array): DecodedGenerateImageQuery | undefined;
37
37
  export type ImageWriteRoots = {
@@ -32,8 +32,20 @@ const IMAGE_MIME_BY_EXTENSION = {
32
32
  ".gif": "image/gif",
33
33
  ".webp": "image/webp",
34
34
  };
35
- /** Cursor names the target file; its extension is the only mime signal sent. */
36
- export function imageMimeForPath(filePath) {
35
+ /** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
36
+ export function imageMimeForPath(filePath, data) {
37
+ if (data) {
38
+ const matches = (signature, offset = 0) => signature.every((byte, index) => data[offset + index] === byte);
39
+ if (matches([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
40
+ return "image/png";
41
+ if (matches([0xff, 0xd8, 0xff]))
42
+ return "image/jpeg";
43
+ if (matches([0x47, 0x49, 0x46, 0x38]) && (data[4] === 0x37 || data[4] === 0x39) && data[5] === 0x61) {
44
+ return "image/gif";
45
+ }
46
+ if (matches([0x52, 0x49, 0x46, 0x46]) && matches([0x57, 0x45, 0x42, 0x50], 8))
47
+ return "image/webp";
48
+ }
37
49
  const dot = path.basename(filePath).lastIndexOf(".");
38
50
  if (dot <= 0)
39
51
  return "image/png";
@@ -12,9 +12,12 @@ export type OpencodeToolDef = {
12
12
  export type HostFilePathKey = "path" | "filePath";
13
13
  /** Host shell tool id. OpenCode 1.x uses `bash`; 2.0 uses `shell`. */
14
14
  export type HostShellTool = "bash" | "shell";
15
+ /** OpenCode 1.x skill params use `name`; OpenCode 2.0 uses `id`. */
16
+ export type HostSkillArgKey = "name" | "id";
15
17
  export type HostToolDialect = {
16
18
  filePathKey: HostFilePathKey;
17
19
  shellTool: HostShellTool;
20
+ skillArgKey: HostSkillArgKey;
18
21
  };
19
22
  export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
20
23
  export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
@@ -23,6 +26,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
23
26
  /**
24
27
  * Infer the host tool dialect from advertised AI SDK schemas.
25
28
  * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
29
+ * Skill: OpenCode 1.x requires `name`; OpenCode 2.0 requires `id`.
26
30
  */
27
31
  export declare function hostToolDialectFromTools(tools: readonly {
28
32
  name?: string;
@@ -16,10 +16,12 @@ export const REQUEST_CONTEXT_RESULT_FIELD = 10;
16
16
  export const OPENCODE_1_TOOL_DIALECT = {
17
17
  filePathKey: "filePath",
18
18
  shellTool: "bash",
19
+ skillArgKey: "name",
19
20
  };
20
21
  export const OPENCODE_2_TOOL_DIALECT = {
21
22
  filePathKey: "path",
22
23
  shellTool: "shell",
24
+ skillArgKey: "id",
23
25
  };
24
26
  function jsonSchemaProperties(schema) {
25
27
  if (!schema || typeof schema !== "object" || Array.isArray(schema))
@@ -46,6 +48,7 @@ export function opencodePathArg(args) {
46
48
  /**
47
49
  * Infer the host tool dialect from advertised AI SDK schemas.
48
50
  * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
51
+ * Skill: OpenCode 1.x requires `name`; OpenCode 2.0 requires `id`.
49
52
  */
50
53
  export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL_DIALECT) {
51
54
  let filePathKey;
@@ -71,7 +74,21 @@ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL
71
74
  // OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
72
75
  filePathKey = shellTool === "shell" ? "path" : defaultDialect.filePathKey;
73
76
  }
74
- return { filePathKey, shellTool };
77
+ const skillProps = jsonSchemaProperties(tools.find((tool) => tool.name === "skill")?.inputSchema);
78
+ let skillArgKey;
79
+ if (skillProps) {
80
+ if ("id" in skillProps && !("name" in skillProps))
81
+ skillArgKey = "id";
82
+ else if ("name" in skillProps)
83
+ skillArgKey = "name";
84
+ }
85
+ if (!skillArgKey) {
86
+ // Opaque skill schema: OpenCode 2 pairs `shell`+`path` with skill `id`.
87
+ skillArgKey = shellTool === "shell" && filePathKey === "path"
88
+ ? "id"
89
+ : defaultDialect.skillArgKey;
90
+ }
91
+ return { filePathKey, shellTool, skillArgKey };
75
92
  }
76
93
  function assignHostFilePath(args, filePath, dialect) {
77
94
  delete args.filePath;
@@ -1257,6 +1274,18 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
1257
1274
  args.path = path;
1258
1275
  return { toolName: "glob", args };
1259
1276
  }
1277
+ case "skill": {
1278
+ // OpenCode 1.x skill requires `name`; OpenCode 2.0 requires `id`. Both
1279
+ // reject the other key (`additionalProperties: false`). Accept either
1280
+ // model/Cursor shape and emit only the dialect key.
1281
+ const value = str(cleaned[dialect.skillArgKey]) ?? str(cleaned.id) ?? str(cleaned.name);
1282
+ const args = { ...cleaned };
1283
+ delete args.id;
1284
+ delete args.name;
1285
+ if (value)
1286
+ args[dialect.skillArgKey] = value;
1287
+ return { toolName: "skill", args };
1288
+ }
1260
1289
  default:
1261
1290
  return { toolName, args: cleaned };
1262
1291
  }
@@ -7,9 +7,12 @@
7
7
  * time is wrong for any session but the one open when the daemon started.
8
8
  *
9
9
  * The 2.0 runtime names a session directory in two places:
10
- * - request header `x-opencode-directory` (per-request; preferred in the LM)
11
- * - `ctx.session.get()` → flat `info.directory`, or legacy
12
- * `info.location.directory`, reachable from `session.hook("context")`
10
+ * - request header `x-opencode-directory` (per-request; preferred in the LM).
11
+ * The host does not set it on model requests; the opencode2 plugin adds it
12
+ * from `session.hook("model.request")`, so it travels with the request
13
+ * instead of depending on this module's in-memory state.
14
+ * - `ctx.session.get()` → `info.location.directory`, reachable from session
15
+ * hooks
13
16
  *
14
17
  * This module records the session-get value by id so the language model can
15
18
  * fall back when the header is absent, same mechanism as `compaction-marker.ts`.
@@ -7,9 +7,12 @@
7
7
  * time is wrong for any session but the one open when the daemon started.
8
8
  *
9
9
  * The 2.0 runtime names a session directory in two places:
10
- * - request header `x-opencode-directory` (per-request; preferred in the LM)
11
- * - `ctx.session.get()` → flat `info.directory`, or legacy
12
- * `info.location.directory`, reachable from `session.hook("context")`
10
+ * - request header `x-opencode-directory` (per-request; preferred in the LM).
11
+ * The host does not set it on model requests; the opencode2 plugin adds it
12
+ * from `session.hook("model.request")`, so it travels with the request
13
+ * instead of depending on this module's in-memory state.
14
+ * - `ctx.session.get()` → `info.location.directory`, reachable from session
15
+ * hooks
13
16
  *
14
17
  * This module records the session-get value by id so the language model can
15
18
  * fall back when the header is absent, same mechanism as `compaction-marker.ts`.
@@ -17,6 +20,7 @@
17
20
  * Bounded so a long-lived server cannot accumulate ids for dead sessions.
18
21
  */
19
22
  import path from "node:path";
23
+ import { trace } from "./debug.js";
20
24
  const MAX_TRACKED_SESSIONS = 256;
21
25
  const sessionDirectories = new Map();
22
26
  export function markSessionDirectory(sessionID, directory) {
@@ -61,7 +65,10 @@ export function opencodeDirectoryHeader(headers) {
61
65
  * Prefer the per-request header, then the session mark, then static options/cwd.
62
66
  */
63
67
  export function resolveSessionWorkspaceRoot(input) {
64
- return path.resolve(opencodeDirectoryHeader(input.headers) ??
65
- getSessionDirectory(input.sessionKey) ??
66
- (input.workspaceRoot || input.cwd || process.cwd()));
68
+ const resolved = opencodeDirectoryHeader(input.headers) ?? getSessionDirectory(input.sessionKey);
69
+ if (resolved)
70
+ return path.resolve(resolved);
71
+ const fallback = input.workspaceRoot || input.cwd || process.cwd();
72
+ trace(`session directory: no header or session mark sessionKey=${input.sessionKey ?? "-"}; fallback=${fallback}`);
73
+ return path.resolve(fallback);
67
74
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.7.3",
3
+ "version": "0.7.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",