cursor-opencode-provider 0.7.4 → 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,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 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
+
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,74 @@
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
+ * 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).
23
+ */
24
+ export declare function buildDynamicCatalogRoutingInstruction(options: {
25
+ toolNames: Iterable<string>;
26
+ knownMcpServers?: Iterable<string>;
27
+ }): string | undefined;
28
+ /**
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`.
36
+ */
37
+ export declare function buildSkillCatalogChangeReminder(options: {
38
+ hasSkillTool: boolean;
39
+ skills: readonly AgentSkillLike[];
40
+ /** Sorted skill ids already admitted; `null` means first admission this conversation. */
41
+ previousSkillIds: readonly string[] | null;
42
+ /**
43
+ * Host skill parameter key from the advertised schema.
44
+ * OpenCode 1.x: `name`. OpenCode 2.0: `id`.
45
+ */
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;
74
+ }): string | undefined;
@@ -0,0 +1,228 @@
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
+ * 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
14
+ *
15
+ * OpenCode 2 still needs `exposeDirectMcpTools` so MCP tools leave Code Mode
16
+ * and enter the AI SDK catalog; without that step they never reach this
17
+ * advertisement path at all.
18
+ */
19
+ const MAX_MCP_SERVERS_IN_GUIDANCE = 8;
20
+ /** Synthetic server builtins and unknown tools are advertised under (`toolsToMcpDescriptors`). */
21
+ const DEFAULT_TOOL_SERVER = "opencode";
22
+ /**
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.
26
+ */
27
+ const admittedSkillIdsByConversation = new Map();
28
+ function listWithOverflow(items, limit) {
29
+ const shown = items.slice(0, limit).map((item) => `\`${item}\``).join(", ");
30
+ const hidden = items.length - limit;
31
+ return hidden > 0 ? `${shown} (+${hidden} more)` : shown;
32
+ }
33
+ /**
34
+ * Configured MCP servers that own at least one advertised tool. Uses the same
35
+ * server resolution as the RequestContext descriptors, so guidance never names
36
+ * a server Cursor was not given. `toolNames` are OpenCode ids (alias
37
+ * `sourceName` when present).
38
+ */
39
+ export function listAdvertisedMcpServers(toolNames, knownMcpServers = []) {
40
+ const known = [...knownMcpServers];
41
+ if (known.length === 0)
42
+ return [];
43
+ const servers = new Set();
44
+ for (const name of toolNames) {
45
+ const { server } = resolveToolServerIdentity(name, DEFAULT_TOOL_SERVER, known);
46
+ if (server !== DEFAULT_TOOL_SERVER)
47
+ servers.add(server);
48
+ }
49
+ return [...servers];
50
+ }
51
+ /** Skill id OpenCode's `skill` tool expects: the discovered id, else `…/skills/<id>/SKILL.md`. */
52
+ export function skillNameFromAgentSkill(skill) {
53
+ const id = typeof skill.id === "string" ? skill.id.trim() : "";
54
+ if (id)
55
+ return id;
56
+ const fullPath = typeof skill.full_path === "string" ? skill.full_path.trim() : "";
57
+ const parts = fullPath.split(/[\\/]+/).filter(Boolean);
58
+ if (parts.length < 2 || parts.at(-1).toLowerCase() !== "skill.md")
59
+ return undefined;
60
+ return parts.at(-2);
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
+ }
82
+ /**
83
+ * Shared system-guidance line: name advertised dynamic-catalog tools and prefer
84
+ * them over Grep/Shell when skills or project rules apply. Concrete skill ids
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).
88
+ */
89
+ export function buildDynamicCatalogRoutingInstruction(options) {
90
+ const names = [...options.toolNames];
91
+ const hasSkill = names.includes("skill");
92
+ const mcpServers = listAdvertisedMcpServers(names, options.knownMcpServers);
93
+ if (!hasSkill && mcpServers.length === 0)
94
+ return undefined;
95
+ const extras = [];
96
+ if (hasSkill)
97
+ extras.push("`skill`");
98
+ if (mcpServers.length > 0) {
99
+ extras.push(`MCP servers such as ${listWithOverflow(mcpServers, MAX_MCP_SERVERS_IN_GUIDANCE)}`);
100
+ }
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");
116
+ }
117
+ function skillEntriesXml(skills, argKey) {
118
+ const lines = ["<available_skills>"];
119
+ for (const skill of skills) {
120
+ const name = skillNameFromAgentSkill(skill);
121
+ if (!name)
122
+ continue;
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>");
131
+ }
132
+ lines.push("</available_skills>");
133
+ return lines;
134
+ }
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 };
151
+ }
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
+ };
173
+ }
174
+ /**
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).
220
+ */
221
+ export function buildSkillCatalogNudge(options) {
222
+ return buildSkillCatalogChangeReminder({
223
+ hasSkillTool: options.hasSkillTool,
224
+ skills: options.skills,
225
+ previousSkillIds: options.previousSkillIds ?? null,
226
+ skillArgKey: options.skillArgKey,
227
+ }).text;
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,4 +1,5 @@
1
1
  export { buildDynamicRequestContext, buildRequestContext, DYNAMIC_REQUEST_CONTEXT_KEYS, materializeRequestContext, requestContextBase, type BuildRequestContextInput, } from "./build.js";
2
+ export { buildDynamicCatalogRoutingInstruction, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, 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, buildSkillCatalogChangeReminder, buildSkillCatalogNudge, takeSkillCatalogChangeReminder, 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`);
@@ -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
  /**
@@ -133,6 +151,8 @@ type ExtractedToolResult = {
133
151
  * are ignored — they are conversation history, not replies for a held-open Run.
134
152
  */
135
153
  export declare function extractTrailingToolResults(prompt: LanguageModelV3CallOptions["prompt"]): ExtractedToolResult[];
154
+ /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
155
+ export declare function hasApprovedUncorrelatedPlanStageResult(prompt: LanguageModelV3CallOptions["prompt"]): boolean;
136
156
  /**
137
157
  * Checkpointed Runs do not resend the system prompt. Keep the workspace root
138
158
  * on the live user message, and require absolute `path` arguments when that is
@@ -147,7 +167,9 @@ export declare function groundCheckpointTurnText(userText: string, checkpoint: b
147
167
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
148
168
  * compaction keeps its dedicated summary prompt unchanged.
149
169
  */
150
- export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string): string | undefined;
170
+ export declare function buildOpenCodeInteractionGuidance(tools: OpencodeToolDef[], isCompaction: boolean, workspaceRoot: string, options?: {
171
+ knownMcpServers?: Iterable<string>;
172
+ }): string | undefined;
151
173
  /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
152
174
  export declare function estimateTokens(chars: number): number;
153
175
  /** Preserve exact request-local Cursor counters as diagnostics. */