cursor-opencode-provider 0.7.5 → 0.7.6

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,7 +293,7 @@ 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)).
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 and mirrors OpenCode's load-when-matches / do-not-reinvoke rules. Mid-Conversation skill reminders fire only when the available-skills catalog changes after baseline admission (same contract as OpenCode `SkillGuidance` / `SkillInstructions`) — not on every user turn that happens to mention a skill id — 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
297
 
298
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`).
299
299
 
@@ -17,27 +17,58 @@ export declare function skillNameFromAgentSkill(skill: AgentSkillLike): string |
17
17
  /**
18
18
  * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
19
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.
20
+ * stay out of the frozen baseline (they live in RequestContext `agent_skills`
21
+ * and in Mid-Conversation updates when the catalog changes — same split as
22
+ * OpenCode's SkillGuidance / SkillInstructions).
22
23
  */
23
24
  export declare function buildDynamicCatalogRoutingInstruction(options: {
24
25
  toolNames: Iterable<string>;
25
26
  knownMcpServers?: Iterable<string>;
26
27
  }): string | undefined;
27
28
  /**
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.
29
+ * OpenCode-shaped Mid-Conversation skill update.
30
+ *
31
+ * Pure: compare `previousSkillIds` (null = not yet admitted this conversation)
32
+ * to the current catalog. First admission returns undefined — baseline
33
+ * RequestContext + routing instruction already carry the list. Later turns
34
+ * only speak when the id set changes (agent switch / discovery growth), matching
35
+ * `SkillGuidance.update` / `SkillInstructions.update`.
33
36
  */
34
- export declare function buildSkillCatalogNudge(options: {
37
+ export declare function buildSkillCatalogChangeReminder(options: {
35
38
  hasSkillTool: boolean;
36
39
  skills: readonly AgentSkillLike[];
37
- userText?: string;
40
+ /** Sorted skill ids already admitted; `null` means first admission this conversation. */
41
+ previousSkillIds: readonly string[] | null;
38
42
  /**
39
43
  * Host skill parameter key from the advertised schema.
40
44
  * OpenCode 1.x: `name`. OpenCode 2.0: `id`.
41
45
  */
42
46
  skillArgKey?: "name" | "id";
47
+ }): {
48
+ text: string | undefined;
49
+ nextSkillIds: string[];
50
+ };
51
+ /**
52
+ * Admit the current skill catalog for a conversation and return a Mid-Conversation
53
+ * reminder only when OpenCode would (catalog changed after baseline admission).
54
+ */
55
+ export declare function takeSkillCatalogChangeReminder(conversationId: string, options: {
56
+ hasSkillTool: boolean;
57
+ skills: readonly AgentSkillLike[];
58
+ skillArgKey?: "name" | "id";
59
+ }): string | undefined;
60
+ export declare function clearSkillCatalogAdmission(conversationId: string): void;
61
+ export declare function transferSkillCatalogAdmission(previousConversationId: string, nextConversationId: string): void;
62
+ export declare function resetSkillCatalogAdmissionsForTests(): void;
63
+ /**
64
+ * @deprecated Prefer `buildSkillCatalogChangeReminder` / `takeSkillCatalogChangeReminder`.
65
+ * Kept as a thin alias for callers that still expect the old name; always returns
66
+ * undefined for userText-based matching (OpenCode never did per-turn id nudges).
67
+ */
68
+ export declare function buildSkillCatalogNudge(options: {
69
+ hasSkillTool: boolean;
70
+ skills: readonly AgentSkillLike[];
71
+ userText?: string;
72
+ skillArgKey?: "name" | "id";
73
+ previousSkillIds?: readonly string[] | null;
43
74
  }): string | undefined;
@@ -4,41 +4,27 @@ import { resolveToolServerIdentity } from "../protocol/tools.js";
4
4
  * top-level function list. Both OpenCode 1.x and OpenCode 2.0 reach the model
5
5
  * as MCP-shaped RequestContext ads, then GetDynamicTools / CallDynamicTool
6
6
  * (exec `mcp_args`). This module builds the shared routing guidance and the
7
- * skill-aware Mid-Conversation nudge used on both hosts.
7
+ * OpenCode-shaped Mid-Conversation skill-catalog update used on both hosts.
8
+ *
9
+ * OpenCode itself does **not** per-turn nudge matched skill ids. It:
10
+ * - Puts name/description guidance in the frozen baseline (`SystemPrompt.skills`
11
+ * / `SkillGuidance` / `SkillInstructions`)
12
+ * - Emits a Mid-Conversation update only when the available-skills list changes
13
+ * - OpenCode 2: skips re-invoke when `<skill_content>` is already in the turn
8
14
  *
9
15
  * OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
10
16
  * and enter the AI SDK catalog; without that step they never reach this
11
17
  * advertisement path at all.
12
18
  */
13
- const MAX_NAMED_SKILLS = 8;
14
19
  const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
15
20
  /** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
16
21
  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
22
  /**
33
- * Match against the live user utterance only. Mid-conversation / MCP instruction
34
- * injections and prior reminders must not re-trigger skill dumps.
23
+ * Skill ids already admitted to the model for a conversation (baseline RequestContext
24
+ * freeze, or the last Mid-Conversation catalog update). First admission is silent —
25
+ * OpenCode puts that list in the system baseline, not a per-turn reminder.
35
26
  */
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
- }
27
+ const admittedSkillIdsByConversation = new Map();
42
28
  function listWithOverflow(items, limit) {
43
29
  const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
44
30
  const hidden = items.length - limit;
@@ -73,11 +59,32 @@ export function skillNameFromAgentSkill(skill) {
73
59
  return undefined;
74
60
  return parts.at(-2);
75
61
  }
62
+ function skillIds(skills) {
63
+ const ids = [];
64
+ const seen = new Set();
65
+ for (const skill of skills) {
66
+ const name = skillNameFromAgentSkill(skill);
67
+ if (!name || seen.has(name))
68
+ continue;
69
+ seen.add(name);
70
+ ids.push(name);
71
+ }
72
+ return [...ids].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
73
+ }
74
+ function sameIdList(left, right) {
75
+ if (left.length !== right.length)
76
+ return false;
77
+ for (let i = 0; i < left.length; i++)
78
+ if (left[i] !== right[i])
79
+ return false;
80
+ return true;
81
+ }
76
82
  /**
77
83
  * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
78
84
  * 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.
85
+ * stay out of the frozen baseline (they live in RequestContext `agent_skills`
86
+ * and in Mid-Conversation updates when the catalog changes — same split as
87
+ * OpenCode's SkillGuidance / SkillInstructions).
81
88
  */
82
89
  export function buildDynamicCatalogRoutingInstruction(options) {
83
90
  const names = [...options.toolNames];
@@ -91,93 +98,131 @@ export function buildDynamicCatalogRoutingInstruction(options) {
91
98
  if (mcpServers.length > 0) {
92
99
  extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
93
100
  }
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)));
101
+ const lines = [
102
+ `- OpenCode host tools that are not in Cursor's native top-level list (including ${extras.join(" and ")}) ` +
103
+ "are reached through GetDynamicTools / CallDynamicTool (or the host's equivalent dynamic catalog). " +
104
+ "When a skill matches or project rules name an MCP server, discover and call those tools that way " +
105
+ "before Grep/Shell fallbacks. Do not narrate that they are unavailable.",
106
+ ];
107
+ if (hasSkill) {
108
+ // Mirror OpenCode 1 SystemPrompt.skills / OC2 SkillInstructions.render.
109
+ lines.push("- Skills provide specialized instructions and workflows for specific tasks. " +
110
+ "Use the `skill` tool to load a skill when a task matches its description " +
111
+ "(RequestContext `agent_skills` lists names and descriptions). " +
112
+ "A skill that is already present in the conversation as a `<skill_content>` block " +
113
+ "does not need to be invoked again.");
114
+ }
115
+ return lines.join("\n");
104
116
  }
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))
117
+ function skillEntriesXml(skills, argKey) {
118
+ const lines = ["<available_skills>"];
119
+ for (const skill of skills) {
120
+ const name = skillNameFromAgentSkill(skill);
121
+ if (!name)
110
122
  continue;
111
- hits += 1;
112
- if (token.length >= 8)
113
- distinctive = true;
114
- if (hits >= 2)
115
- return true;
123
+ const description = typeof skill.description === "string" ? skill.description : "";
124
+ lines.push(" <skill>");
125
+ if (argKey === "id")
126
+ lines.push(` <id>${name}</id>`);
127
+ lines.push(` <name>${name}</name>`);
128
+ if (description)
129
+ lines.push(` <description>${description}</description>`);
130
+ lines.push(" </skill>");
116
131
  }
117
- // Single distinctive token (library names, skill ids) is enough.
118
- return distinctive;
132
+ lines.push("</available_skills>");
133
+ return lines;
119
134
  }
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;
135
+ /**
136
+ * OpenCode-shaped Mid-Conversation skill update.
137
+ *
138
+ * Pure: compare `previousSkillIds` (null = not yet admitted this conversation)
139
+ * to the current catalog. First admission returns undefined — baseline
140
+ * RequestContext + routing instruction already carry the list. Later turns
141
+ * only speak when the id set changes (agent switch / discovery growth), matching
142
+ * `SkillGuidance.update` / `SkillInstructions.update`.
143
+ */
144
+ export function buildSkillCatalogChangeReminder(options) {
145
+ const nextSkillIds = skillIds(options.skills);
146
+ if (!options.hasSkillTool) {
147
+ return { text: undefined, nextSkillIds: options.previousSkillIds ? [...options.previousSkillIds] : [] };
148
+ }
149
+ if (options.previousSkillIds === null) {
150
+ return { text: undefined, nextSkillIds };
127
151
  }
128
- return false;
152
+ if (sameIdList(options.previousSkillIds, nextSkillIds)) {
153
+ return { text: undefined, nextSkillIds };
154
+ }
155
+ const argKey = options.skillArgKey === "id" ? "id" : "name";
156
+ const callHint = argKey === "id"
157
+ ? "Call `skill` with `{ \"id\": \"<skill-id>\" }` (host schema key is `id`)."
158
+ : "Call `skill` with `{ \"name\": \"<skill-id>\" }` (host schema key is `name`).";
159
+ const body = nextSkillIds.length === 0
160
+ ? ["Skill guidance is no longer available. Do not use any previously listed skill."]
161
+ : [
162
+ "The available skills have changed. This list supersedes the previous available skills list.",
163
+ "Skills provide specialized instructions and workflows for specific tasks.",
164
+ "Use the skill tool to load a skill when a task matches its description.",
165
+ "The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
166
+ `OpenCode skills are invoked with the host \`skill\` tool through GetDynamicTools / CallDynamicTool (MCP server \`${DEFAULT_TOOL_SERVER}\`, tool \`skill\`), not by Grep/Read of SKILL.md. ${callHint}`,
167
+ ...skillEntriesXml(options.skills, argKey),
168
+ ];
169
+ return {
170
+ text: `<system_reminder>\n${body.join("\n")}\n</system_reminder>`,
171
+ nextSkillIds,
172
+ };
129
173
  }
130
174
  /**
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.
175
+ * Admit the current skill catalog for a conversation and return a Mid-Conversation
176
+ * reminder only when OpenCode would (catalog changed after baseline admission).
177
+ */
178
+ export function takeSkillCatalogChangeReminder(conversationId, options) {
179
+ const previous = conversationId
180
+ ? (admittedSkillIdsByConversation.get(conversationId) ?? null)
181
+ : null;
182
+ const { text, nextSkillIds } = buildSkillCatalogChangeReminder({
183
+ ...options,
184
+ previousSkillIds: previous,
185
+ });
186
+ if (!conversationId)
187
+ return text;
188
+ if (!options.hasSkillTool && previous === null && nextSkillIds.length === 0) {
189
+ return text;
190
+ }
191
+ admittedSkillIdsByConversation.delete(conversationId);
192
+ admittedSkillIdsByConversation.set(conversationId, nextSkillIds);
193
+ while (admittedSkillIdsByConversation.size > 256) {
194
+ const oldest = admittedSkillIdsByConversation.keys().next().value;
195
+ if (!oldest)
196
+ break;
197
+ admittedSkillIdsByConversation.delete(oldest);
198
+ }
199
+ return text;
200
+ }
201
+ export function clearSkillCatalogAdmission(conversationId) {
202
+ admittedSkillIdsByConversation.delete(conversationId);
203
+ }
204
+ export function transferSkillCatalogAdmission(previousConversationId, nextConversationId) {
205
+ if (!previousConversationId || !nextConversationId)
206
+ return;
207
+ const prior = admittedSkillIdsByConversation.get(previousConversationId);
208
+ admittedSkillIdsByConversation.delete(previousConversationId);
209
+ admittedSkillIdsByConversation.delete(nextConversationId);
210
+ if (prior)
211
+ admittedSkillIdsByConversation.set(nextConversationId, prior);
212
+ }
213
+ export function resetSkillCatalogAdmissionsForTests() {
214
+ admittedSkillIdsByConversation.clear();
215
+ }
216
+ /**
217
+ * @deprecated Prefer `buildSkillCatalogChangeReminder` / `takeSkillCatalogChangeReminder`.
218
+ * Kept as a thin alias for callers that still expect the old name; always returns
219
+ * undefined for userText-based matching (OpenCode never did per-turn id nudges).
136
220
  */
137
221
  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>");
222
+ return buildSkillCatalogChangeReminder({
223
+ hasSkillTool: options.hasSkillTool,
224
+ skills: options.skills,
225
+ previousSkillIds: options.previousSkillIds ?? null,
226
+ skillArgKey: options.skillArgKey,
227
+ }).text;
183
228
  }
@@ -1,6 +1,7 @@
1
1
  import { buildDynamicRequestContext, buildRequestContext, materializeRequestContext, requestContextBase, } from "./build.js";
2
2
  import { clearContextEpoch, endContextEpoch, resetContextEpochsForTests } from "./epoch.js";
3
3
  import { clearOverlayHold, resetOverlayHoldsForTests, transferOverlayHold, } from "./overlay.js";
4
+ import { clearSkillCatalogAdmission, resetSkillCatalogAdmissionsForTests, transferSkillCatalogAdmission, } from "./dynamic-catalog.js";
4
5
  import { trace } from "../debug.js";
5
6
  import { encodeMessage } from "../protocol/messages.js";
6
7
  /**
@@ -44,6 +45,7 @@ function remember(conversationId, context) {
44
45
  byConversationId.delete(oldest);
45
46
  materializedByConversationId.delete(oldest);
46
47
  clearOverlayHold(oldest);
48
+ clearSkillCatalogAdmission(oldest);
47
49
  }
48
50
  }
49
51
  /** Frozen stable RequestContext base for this conversation, if any. */
@@ -67,6 +69,7 @@ export function clearFrozenRequestContext(conversationId) {
67
69
  byConversationId.delete(conversationId);
68
70
  materializedByConversationId.delete(conversationId);
69
71
  clearOverlayHold(conversationId);
72
+ clearSkillCatalogAdmission(conversationId);
70
73
  clearContextEpoch(conversationId);
71
74
  }
72
75
  /**
@@ -89,6 +92,7 @@ export function transferFrozenRequestContext(previousConversationId, nextConvers
89
92
  // also drops epoch state for each id.
90
93
  endContextEpoch(previousConversationId, nextConversationId);
91
94
  transferOverlayHold(previousConversationId, nextConversationId);
95
+ transferSkillCatalogAdmission(previousConversationId, nextConversationId);
92
96
  byConversationId.delete(previousConversationId);
93
97
  materializedByConversationId.delete(previousConversationId);
94
98
  byConversationId.delete(nextConversationId);
@@ -110,6 +114,7 @@ export function resetFrozenRequestContextsForTests() {
110
114
  materializedByConversationId.clear();
111
115
  buildsByConversationId.clear();
112
116
  resetOverlayHoldsForTests();
117
+ resetSkillCatalogAdmissionsForTests();
113
118
  resetContextEpochsForTests();
114
119
  }
115
120
  function sameBytes(a, b) {
@@ -1,5 +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
+ export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, listAdvertisedMcpServers, skillNameFromAgentSkill, type AgentSkillLike, } from "./dynamic-catalog.js";
3
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, type AdmitContextEpochInput, type AdmitContextEpochResult, type ContextEpoch, type ContextSourceSnapshot, } from "./epoch.js";
@@ -1,5 +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
+ export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, listAdvertisedMcpServers, skillNameFromAgentSkill, } from "./dynamic-catalog.js";
3
3
  export { clearFrozenRequestContext, getFrozenRequestContext, getOrBuildRequestContext, MAX_FROZEN_REQUEST_CONTEXTS, resetFrozenRequestContextsForTests, setFrozenRequestContext, transferFrozenRequestContext, } from "./frozen.js";
4
4
  export { MAX_OVERLAY_HOLDS, holdCapabilityOverlay, resetOverlayHoldsForTests, } from "./overlay.js";
5
5
  export { admitContextEpoch, appendMidConversationMessage, clearContextEpoch, endContextEpoch, getContextEpoch, MAX_CONTEXT_EPOCHS, resetContextEpochsForTests, } from "./epoch.js";
@@ -68,6 +68,24 @@ export declare function findContinuationSession(toolResults: Array<{
68
68
  sessionId: string;
69
69
  execId: number;
70
70
  }>): CursorSession | undefined;
71
+ /**
72
+ * True when `incoming` is a non-empty proper subset of `parent` by tool name.
73
+ * Used to spot in-session helpers that reuse the parent OpenCode session id
74
+ * with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
75
+ */
76
+ export declare function isProperCatalogSubset(incoming: ReadonlyArray<{
77
+ name?: string;
78
+ }>, parent: ReadonlyArray<{
79
+ name?: string;
80
+ }>): boolean;
81
+ /**
82
+ * An open parent Run for this OpenCode session whose catalog strictly contains
83
+ * the incoming tools is treated as an in-session helper. Those calls must not
84
+ * cancel/supersede the parent (that remints on the trailing tool result).
85
+ */
86
+ export declare function shouldIsolateInSessionHelper(openCodeSessionId: string | undefined, incomingTools: ReadonlyArray<{
87
+ name?: string;
88
+ }>): boolean;
71
89
  /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
72
90
  export declare const FRESH_TURN_DRAIN_TIMEOUT_MS = 8000;
73
91
  /**
@@ -34,7 +34,7 @@ import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMo
34
34
  import { getOrBuildRequestContext } from "./context/frozen.js";
35
35
  import { getHeldOverlaySkills } from "./context/overlay.js";
36
36
  import { loadMergedConfig } from "./context/rules.js";
37
- import { buildDynamicCatalogRoutingInstruction, buildSkillCatalogNudge, } from "./context/dynamic-catalog.js";
37
+ import { buildDynamicCatalogRoutingInstruction, takeSkillCatalogChangeReminder, } from "./context/dynamic-catalog.js";
38
38
  import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
39
39
  import { workspaceRootFromRequestContext } from "./context/env.js";
40
40
  import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
@@ -542,19 +542,34 @@ async function doStreamImpl(modelId, options, callOptions) {
542
542
  if (historical > 0) {
543
543
  trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
544
544
  }
545
- // Host may start a new user turn while the prior Run still has pending
546
- // tools. Finish that Run on the same conversation (cancel stranded execs,
547
- // drain turn_ended) before opening the new Run so the checkpoint prefix
548
- // is preserved. registerSession will not close a prior Run that still
549
- // has real pending execs; a failed prepare leaves that Run held.
550
- try {
551
- await preparePriorSessionForFreshTurn(sessionKey);
545
+ // An in-session helper (title/memory/task child) that reuses the parent
546
+ // OpenCode session id with a strictly smaller catalog must not cancel or
547
+ // supersede the held parent Run — that remints (`interrupted-run`) when
548
+ // the trailing tool result returns. Isolate onto an ephemeral conversation.
549
+ const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
550
+ if (busyPrior
551
+ && !busyPrior.closed
552
+ && isProperCatalogSubset(extractTools(callOptions), busyPrior.toolCatalog ?? [])) {
553
+ trace(`fresh turn: isolating in-session helper — incomingTools subset of ` +
554
+ `parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
555
+ `priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
556
+ session = await openSession({ isolate: true });
552
557
  }
553
- catch (error) {
554
- trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
555
- `any still-pending prior held — ${error.message}`);
558
+ else {
559
+ // Host may start a new user turn while the prior Run still has pending
560
+ // tools. Finish that Run on the same conversation (cancel stranded execs,
561
+ // drain turn_ended) before opening the new Run so the checkpoint prefix
562
+ // is preserved. registerSession will not close a prior Run that still
563
+ // has real pending execs; a failed prepare leaves that Run held.
564
+ try {
565
+ await preparePriorSessionForFreshTurn(sessionKey);
566
+ }
567
+ catch (error) {
568
+ trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
569
+ `any still-pending prior held — ${error.message}`);
570
+ }
571
+ session = await openSession();
556
572
  }
557
- session = await openSession();
558
573
  }
559
574
  }
560
575
  let activeSession = session;
@@ -756,6 +771,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
756
771
  const prompt = callOptions.prompt;
757
772
  const incomingTools = extractTools(callOptions);
758
773
  const sessionKey = opencodeSessionKey(callOptions);
774
+ const isolateHelper = startOptions?.isolate === true;
759
775
  const cacheDir = opencodeGlobalCacheDir();
760
776
  if (sessionKey) {
761
777
  const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
@@ -807,6 +823,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
807
823
  let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
808
824
  let resuming = !!resumeRecovery;
809
825
  const lifecycle = !allowTools && !isCompaction && !recovery;
826
+ // Isolated helpers must not share the parent's openCodeSessionId or sticky
827
+ // conversation: registerSession would otherwise supersede the held parent Run.
828
+ const ephemeralRun = lifecycle || isolateHelper;
810
829
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
811
830
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
812
831
  // projects, so its static option is only a last-resort fallback. Prefer the
@@ -841,9 +860,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
841
860
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
842
861
  : bindConversationId(sessionKey, {
843
862
  reset: resetState.reset || recovery?.kind === "rebase",
844
- ephemeral: lifecycle,
863
+ ephemeral: ephemeralRun,
845
864
  });
846
- let conversationState = lifecycle
865
+ let conversationState = ephemeralRun
847
866
  ? undefined
848
867
  : resuming
849
868
  ? resumeRecovery.checkpoint
@@ -880,9 +899,6 @@ async function startSession(modelId, token, callOptions, options, startOptions)
880
899
  let userText = recovery?.kind === "rebase"
881
900
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
882
901
  : (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;
886
902
  // After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
887
903
  // (same <system_reminder> contract the CLI uses after flipping unifiedMode).
888
904
  const startedWithCheckpoint = !!conversationState;
@@ -1007,17 +1023,15 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1007
1023
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
1008
1024
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
1009
1025
  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.
1026
+ // Issue #29: after skills are in RequestContext, admit the catalog. OpenCode
1027
+ // only Mid-Conversation-updates when the available-skills list changes
1028
+ // (SkillGuidance / SkillInstructions) — never per-turn matched-id nudges.
1029
+ // Gate on the host-permitted set, not the epoch-held advertisement.
1015
1030
  if (!isCompaction && !lifecycle) {
1016
1031
  const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1017
- const skillNudge = buildSkillCatalogNudge({
1032
+ const skillNudge = takeSkillCatalogChangeReminder(conversationId, {
1018
1033
  hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
1019
1034
  skills: getHeldOverlaySkills(conversationId),
1020
- userText: skillMatchText,
1021
1035
  skillArgKey: skillDialect.skillArgKey,
1022
1036
  });
1023
1037
  userText = appendMidConversationMessage(userText, skillNudge);
@@ -1159,7 +1173,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1159
1173
  createPlanInTurn: false,
1160
1174
  switchModeInTurn: false,
1161
1175
  },
1162
- openCodeSessionId: lifecycle ? undefined : sessionKey,
1176
+ openCodeSessionId: ephemeralRun ? undefined : sessionKey,
1163
1177
  hostAgent,
1164
1178
  stableSystemPromptHash: frozenSystemPromptHash,
1165
1179
  postCompactionRebase: isCompaction,
@@ -1172,7 +1186,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1172
1186
  // Seed from the per-OpenCode-session copy: merges in this turn (and after
1173
1187
  // checkpoint resumes/rebases, which rebuild the session here) expand
1174
1188
  // against the last observed host list, not an empty one.
1175
- mirroredTodos: snapshotMirroredTodosBySession(lifecycle ? undefined : sessionKey),
1189
+ mirroredTodos: snapshotMirroredTodosBySession(ephemeralRun ? undefined : sessionKey),
1176
1190
  nextBridgedExecId: 900_000,
1177
1191
  blobs: new Map(),
1178
1192
  toolDescriptors,
@@ -1276,6 +1290,39 @@ export function findContinuationSession(toolResults) {
1276
1290
  }
1277
1291
  return undefined;
1278
1292
  }
1293
+ /**
1294
+ * True when `incoming` is a non-empty proper subset of `parent` by tool name.
1295
+ * Used to spot in-session helpers that reuse the parent OpenCode session id
1296
+ * with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
1297
+ */
1298
+ export function isProperCatalogSubset(incoming, parent) {
1299
+ if (incoming.length === 0 || parent.length === 0)
1300
+ return false;
1301
+ if (incoming.length >= parent.length)
1302
+ return false;
1303
+ const parentNames = new Set(parent.map((tool) => tool.name).filter((name) => !!name));
1304
+ if (parentNames.size === 0)
1305
+ return false;
1306
+ let matched = 0;
1307
+ for (const tool of incoming) {
1308
+ const name = tool.name;
1309
+ if (!name || !parentNames.has(name))
1310
+ return false;
1311
+ matched++;
1312
+ }
1313
+ return matched > 0 && matched < parentNames.size;
1314
+ }
1315
+ /**
1316
+ * An open parent Run for this OpenCode session whose catalog strictly contains
1317
+ * the incoming tools is treated as an in-session helper. Those calls must not
1318
+ * cancel/supersede the parent (that remints on the trailing tool result).
1319
+ */
1320
+ export function shouldIsolateInSessionHelper(openCodeSessionId, incomingTools) {
1321
+ const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
1322
+ if (!prior || prior.closed)
1323
+ return false;
1324
+ return isProperCatalogSubset(incomingTools, prior.toolCatalog ?? []);
1325
+ }
1279
1326
  /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
1280
1327
  export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
1281
1328
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",