cursor-opencode-provider 0.7.6 → 0.8.0

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.
Files changed (60) hide show
  1. package/README.md +9 -7
  2. package/dist/agent-url.js +2 -2
  3. package/dist/auth-renewal.d.ts +85 -0
  4. package/dist/auth-renewal.js +284 -0
  5. package/dist/auth.d.ts +38 -22
  6. package/dist/auth.js +79 -96
  7. package/dist/classic-tools.d.ts +15 -0
  8. package/dist/classic-tools.js +45 -0
  9. package/dist/context/build.d.ts +42 -5
  10. package/dist/context/build.js +96 -92
  11. package/dist/context/dynamic-catalog.d.ts +2 -58
  12. package/dist/context/dynamic-catalog.js +10 -160
  13. package/dist/context/epoch.d.ts +6 -4
  14. package/dist/context/epoch.js +22 -10
  15. package/dist/context/frozen.js +37 -14
  16. package/dist/context/index.d.ts +1 -1
  17. package/dist/context/index.js +1 -1
  18. package/dist/context/overlay.d.ts +2 -23
  19. package/dist/context/overlay.js +6 -30
  20. package/dist/context/paths.d.ts +7 -4
  21. package/dist/context/paths.js +13 -9
  22. package/dist/context/rules.d.ts +3 -15
  23. package/dist/context/rules.js +10 -186
  24. package/dist/debug.d.ts +2 -0
  25. package/dist/debug.js +4 -0
  26. package/dist/errors.d.ts +13 -0
  27. package/dist/errors.js +27 -3
  28. package/dist/host-agent-mode.js +2 -2
  29. package/dist/image-save.js +3 -3
  30. package/dist/index.d.ts +10 -0
  31. package/dist/language-model.d.ts +41 -8
  32. package/dist/language-model.js +391 -98
  33. package/dist/opencode2/integration.d.ts +30 -7
  34. package/dist/opencode2/integration.js +75 -46
  35. package/dist/plan-execution-kickoff.js +2 -2
  36. package/dist/plugin-opencode2.js +10 -16
  37. package/dist/plugin.d.ts +0 -10
  38. package/dist/plugin.js +138 -109
  39. package/dist/protocol/blob-store.js +3 -2
  40. package/dist/protocol/conversation-bind.js +1 -1
  41. package/dist/protocol/conversation-persistence.d.ts +2 -0
  42. package/dist/protocol/conversation-persistence.js +11 -1
  43. package/dist/protocol/conversation-state.d.ts +2 -0
  44. package/dist/protocol/conversation-state.js +19 -0
  45. package/dist/protocol/create-plan.js +2 -1
  46. package/dist/protocol/messages.js +22 -4
  47. package/dist/protocol/request.d.ts +11 -9
  48. package/dist/protocol/request.js +15 -25
  49. package/dist/protocol/tools.d.ts +17 -10
  50. package/dist/protocol/tools.js +53 -93
  51. package/dist/protocol/turn-provenance.d.ts +52 -0
  52. package/dist/protocol/turn-provenance.js +174 -0
  53. package/dist/session.d.ts +9 -1
  54. package/dist/shared.d.ts +7 -0
  55. package/dist/shared.js +7 -0
  56. package/package.json +1 -1
  57. package/dist/context/agents.d.ts +0 -12
  58. package/dist/context/agents.js +0 -136
  59. package/dist/context/skills.d.ts +0 -11
  60. package/dist/context/skills.js +0 -110
package/dist/auth.js CHANGED
@@ -1,23 +1,24 @@
1
- import { CURSOR_API_HOST, CURSOR_WEBSITE_HOST } from "./shared.js";
1
+ import { CURSOR_API_HOST, CURSOR_OAUTH_CLIENT_ID, CURSOR_WEBSITE_HOST } from "./shared.js";
2
2
  import { withAbortDeadline } from "./deadline.js";
3
+ import { errorMessage } from "./debug.js";
3
4
  const API_BASE = `https://${CURSOR_API_HOST}`;
4
5
  const AUTH_REQUEST_TIMEOUT_MS = 5_000;
6
+ /** Cursor IDE aborts its session refresh after 20 s (`_performAccessTokenRefresh`). */
7
+ const SESSION_REFRESH_TIMEOUT_MS = 20_000;
8
+ /** Server error code Cursor's IDE and CLI both surface as a sign-in policy block. */
9
+ const SIGN_IN_POLICY_VIOLATION = "sign_in_policy_violation";
5
10
  export class AuthExchangeError extends Error {
6
11
  cause;
7
- constructor(message, cause) {
12
+ kind;
13
+ status;
14
+ constructor(message, cause, kind = "transient", status) {
8
15
  super(message);
9
16
  this.cause = cause;
17
+ this.kind = kind;
18
+ this.status = status;
10
19
  this.name = "AuthExchangeError";
11
20
  }
12
21
  }
13
- export class AuthRefreshError extends Error {
14
- cause;
15
- constructor(message, cause) {
16
- super(message);
17
- this.cause = cause;
18
- this.name = "AuthRefreshError";
19
- }
20
- }
21
22
  export class AuthPollError extends Error {
22
23
  cause;
23
24
  constructor(message, cause) {
@@ -69,10 +70,19 @@ function base64url(bytes) {
69
70
  .replace(/\//g, "_")
70
71
  .replace(/=+$/, "");
71
72
  }
73
+ /** A raw Cursor API key (as opposed to a JWT already exchanged from one). */
74
+ export function isExchangeableApiKey(value) {
75
+ return value.startsWith("crsr_");
76
+ }
72
77
  // ── Mode A: pass-through auth token ──
73
78
  export function useAuthToken(token) {
74
79
  return { accessToken: token };
75
80
  }
81
+ /**
82
+ * Exchange a raw `crsr_` API key for a short-lived JWT. This is also how an
83
+ * API-key login is renewed: Cursor CLI re-runs the exchange and never uses the
84
+ * returned refresh token (`auth-refresh.ts` → `loginWithApiKey`).
85
+ */
76
86
  export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
77
87
  return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthExchangeError("API key exchange timed out"), async (signal) => {
78
88
  let res;
@@ -91,7 +101,18 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
91
101
  throw new AuthExchangeError("API key exchange request failed", cause);
92
102
  }
93
103
  if (!res.ok) {
94
- throw new AuthExchangeError(`API key exchange failed: ${res.status} ${res.statusText}`);
104
+ let errorCode;
105
+ if (res.status === 403) {
106
+ errorCode = (await res.json().catch(() => undefined))?.error;
107
+ }
108
+ // Only an authentication refusal says the key itself is bad; rate
109
+ // limits, timeouts, and server errors are worth another attempt.
110
+ const kind = errorCode === SIGN_IN_POLICY_VIOLATION
111
+ ? "policy"
112
+ : res.status === 401 || res.status === 403 ? "rejected" : "transient";
113
+ throw new AuthExchangeError(kind === "policy"
114
+ ? `API key exchange blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})`
115
+ : `API key exchange failed: ${res.status} ${res.statusText}`, undefined, kind, res.status);
95
116
  }
96
117
  let body;
97
118
  try {
@@ -106,97 +127,59 @@ export async function exchangeApiKey(apiKey, baseUrl = API_BASE) {
106
127
  return { accessToken: body.accessToken, refreshToken: body.refreshToken };
107
128
  });
108
129
  }
109
- export async function refreshAccessToken(refreshToken, baseUrl = API_BASE) {
110
- return withAbortDeadline(AUTH_REQUEST_TIMEOUT_MS, () => new AuthRefreshError("Token refresh timed out"), async (signal) => {
111
- let res;
112
- try {
113
- res = await fetch(`${baseUrl}/auth/token`, {
114
- method: "POST",
115
- headers: { "Content-Type": "application/json" },
116
- body: JSON.stringify({ refreshToken }),
117
- signal,
118
- });
119
- }
120
- catch (cause) {
121
- throw new AuthRefreshError("Token refresh request failed", cause);
122
- }
123
- if (!res.ok) {
124
- throw new AuthRefreshError(`Token refresh failed: ${res.status} ${res.statusText}`);
125
- }
126
- let body;
127
- try {
128
- body = await res.json();
129
- }
130
- catch (cause) {
131
- throw new AuthRefreshError("Token refresh returned malformed JSON", cause);
132
- }
133
- if (typeof body.accessToken !== "string" || typeof body.refreshToken !== "string") {
134
- throw new AuthRefreshError("Refresh response missing tokens");
135
- }
136
- return { accessToken: body.accessToken, refreshToken: body.refreshToken };
137
- });
138
- }
139
- // Cache JWTs obtained via API-key exchange so doStream doesn't re-exchange
140
- // on every turn when the caller only supplied `apiKey`.
141
- const _apiKeyTokenCache = new Map();
142
- const inflightBearer = new Map();
143
- /** Clear the apiKey→JWT cache (tests). */
144
- export function clearBearerTokenCache() {
145
- _apiKeyTokenCache.clear();
146
- inflightBearer.clear();
147
- }
148
130
  /**
149
- * Resolve a Bearer JWT for Cursor API calls. Prefer an already-exchanged
150
- * `accessToken`; otherwise exchange (and cache) from `apiKey`, refreshing
151
- * when the cached JWT is near expiry.
131
+ * Renew a browser-login session token, exactly as Cursor's IDE does
132
+ * (`_performAccessTokenRefresh`): `POST /oauth/token` with a refresh_token
133
+ * grant and the IDE's client id. The response carries no new refresh token;
134
+ * the IDE stores the new access token as both, and so must callers.
152
135
  *
153
- * `apiKey` is only ever a raw exchangeable secret when it has Cursor's
154
- * `crsr_` prefix. Callers that generically forward whatever credential value
155
- * they hold — e.g. a host's package-agnostic "aisdk:" SDK loader, which
156
- * doesn't distinguish our OAuth vs. API-key connection methods and may pass
157
- * an already-issued JWT through the `apiKey` field — hand us a token that's
158
- * already good to use as-is; POSTing it to the exchange endpoint 401s.
136
+ * A rejected token is not an HTTP error: Cursor answers 200 with
137
+ * `{"access_token":"","shouldLogout":true}`, so the body decides the outcome.
138
+ * Never throws; every failure is a classified result.
159
139
  */
160
- export async function resolveBearerToken(input) {
161
- if (input.accessToken)
162
- return input.accessToken;
163
- if (!input.apiKey) {
164
- throw new Error("Cursor provider: no access token or API key provided");
165
- }
166
- if (!input.apiKey.startsWith("crsr_"))
167
- return input.apiKey;
168
- const baseUrl = input.baseUrl ?? API_BASE;
169
- const cached = _apiKeyTokenCache.get(input.apiKey);
170
- if (cached && !isExpiringSoon(cached.accessToken)) {
171
- return cached.accessToken;
172
- }
173
- const inflightKey = `${baseUrl}\0${input.apiKey}`;
174
- const existing = inflightBearer.get(inflightKey);
175
- if (existing)
176
- return existing;
177
- async function refreshOrExchange() {
178
- if (cached) {
179
- try {
180
- const refreshed = await refreshAccessToken(cached.refreshToken, baseUrl);
181
- _apiKeyTokenCache.set(input.apiKey, refreshed);
182
- return refreshed.accessToken;
140
+ export async function refreshCursorSession(refreshToken, baseUrl = API_BASE) {
141
+ try {
142
+ return await withAbortDeadline(SESSION_REFRESH_TIMEOUT_MS, () => new Error("session refresh timed out"), async (signal) => {
143
+ const res = await fetch(`${baseUrl}/oauth/token`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "application/json",
147
+ "x-cursor-client-type": "cli",
148
+ },
149
+ body: JSON.stringify({
150
+ grant_type: "refresh_token",
151
+ client_id: CURSOR_OAUTH_CLIENT_ID,
152
+ refresh_token: refreshToken,
153
+ }),
154
+ signal,
155
+ });
156
+ if (!res.ok) {
157
+ return {
158
+ ok: false,
159
+ kind: "transient",
160
+ status: res.status,
161
+ message: `session refresh failed: ${res.status} ${res.statusText}`,
162
+ };
183
163
  }
184
- catch {
185
- // Fall through to a fresh exchange.
164
+ const body = await res.json().catch(() => undefined);
165
+ if (body?.shouldLogout === true) {
166
+ return body.error === SIGN_IN_POLICY_VIOLATION
167
+ ? { ok: false, kind: "policy", status: res.status, message: `session blocked by Cursor sign-in policy (${SIGN_IN_POLICY_VIOLATION})` }
168
+ : { ok: false, kind: "logout", status: res.status, message: "Cursor ended the session (shouldLogout)" };
186
169
  }
187
- }
188
- const pair = await exchangeApiKey(input.apiKey, baseUrl);
189
- _apiKeyTokenCache.set(input.apiKey, pair);
190
- return pair.accessToken;
191
- }
192
- const pending = refreshOrExchange();
193
- inflightBearer.set(inflightKey, pending);
194
- try {
195
- return await pending;
170
+ // The IDE would store an empty token here; treat it as a failed attempt.
171
+ if (typeof body?.access_token !== "string" || body.access_token === "") {
172
+ return { ok: false, kind: "transient", status: res.status, message: "session refresh returned no access token" };
173
+ }
174
+ return { ok: true, accessToken: body.access_token };
175
+ });
196
176
  }
197
- finally {
198
- if (inflightBearer.get(inflightKey) === pending)
199
- inflightBearer.delete(inflightKey);
177
+ catch (cause) {
178
+ return {
179
+ ok: false,
180
+ kind: "transient",
181
+ message: `session refresh request failed: ${errorMessage(cause)}`,
182
+ };
200
183
  }
201
184
  }
202
185
  // ── Mode C: PKCE browser login ──
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Classic plugin tools built with the host's own `tool()` helper when it can
3
+ * be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
4
+ * of a plugin module as a plugin (`getLegacyPlugins`), so that module may
5
+ * export `CursorPlugin` only.
6
+ */
7
+ export declare function loadClassicTools(options?: {
8
+ importModule?: (specifier: string) => Promise<{
9
+ tool?: any;
10
+ }>;
11
+ configDirs?: string[];
12
+ }): Promise<{
13
+ webSearch: any;
14
+ imageSave: any;
15
+ }>;
@@ -0,0 +1,45 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { opencodeGlobalConfigDirs } from "./context/paths.js";
4
+ import { createOpenCodeWebSearchTool, openCodeWebSearchTool } from "./web-search-tool.js";
5
+ import { createCursorImageSaveTool, cursorImageSaveTool } from "./image-save-tool.js";
6
+ /**
7
+ * Classic plugin tools built with the host's own `tool()` helper when it can
8
+ * be found. Lives outside `plugin.ts` because OpenCode 1.x calls every export
9
+ * of a plugin module as a plugin (`getLegacyPlugins`), so that module may
10
+ * export `CursorPlugin` only.
11
+ */
12
+ export async function loadClassicTools(options = {}) {
13
+ const configDir = options.configDirs?.[0] ?? opencodeGlobalConfigDirs()[0];
14
+ const candidates = [
15
+ ...(configDir
16
+ ? [path.join(configDir, "node_modules", "@opencode-ai", "plugin", "dist", "index.js")]
17
+ : []),
18
+ "@opencode-ai/plugin",
19
+ ];
20
+ const importModule = options.importModule ?? ((specifier) => import(specifier));
21
+ // The global config copy is the host-owned installation; bare import is a fallback.
22
+ for (const candidate of candidates) {
23
+ try {
24
+ const specifier = path.win32.isAbsolute(candidate) && !path.isAbsolute(candidate)
25
+ ? new URL(`file:///${candidate.replaceAll("\\", "/")}`).href
26
+ : path.isAbsolute(candidate)
27
+ ? pathToFileURL(candidate).href
28
+ : candidate;
29
+ const module = await importModule(specifier);
30
+ if (typeof module.tool === "function" && module.tool.schema) {
31
+ const factory = { tool: module.tool, schema: module.tool.schema };
32
+ return {
33
+ webSearch: createOpenCodeWebSearchTool(factory),
34
+ imageSave: createCursorImageSaveTool(factory),
35
+ };
36
+ }
37
+ }
38
+ catch {
39
+ // Try the next host-owned/normal resolution location.
40
+ }
41
+ }
42
+ // `tool()` is an identity helper; the fallback definitions use plain JSON
43
+ // Schema accepted by OpenCode's legacy registry when no Zod helper is present.
44
+ return { webSearch: openCodeWebSearchTool, imageSave: cursorImageSaveTool };
45
+ }
@@ -11,15 +11,52 @@ export type BuildRequestContextInput = {
11
11
  * guidance MCP server ids). Skips a second `loadMergedConfig` disk read.
12
12
  */
13
13
  mergedConfig?: OpencodeJson;
14
+ /** Host system context to deliver as the frozen system-instructions rule. */
15
+ systemInstructions?: SystemInstructions;
14
16
  };
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"];
17
+ /**
18
+ * Host system context (OpenCode's system prompt plus this provider's
19
+ * interaction guidance) for one Cursor conversation.
20
+ */
21
+ export type SystemInstructions = {
22
+ text: string;
23
+ /**
24
+ * True when `text` is this conversation's own frozen baseline (a Context
25
+ * Epoch started in this process, or an ephemeral Run's prompt): it replaces
26
+ * any rule already frozen. False when a restart recovered the epoch without
27
+ * its bytes: the persisted rule wins and `text` only fills a missing one.
28
+ */
29
+ authoritative: boolean;
30
+ };
31
+ /** Rule name Cursor shows for the host system context (not a file). */
32
+ export declare const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
33
+ /**
34
+ * Cursor does not follow a client-seeded `system` message, and applies a rule
35
+ * only by its type. The Cursor CLI sends AGENTS.md as `alwaysApply`
36
+ * (`CursorRuleType.global`) and the IDE ships its own guidance as a non-file
37
+ * global rule; the host system context goes out the same way, once, frozen
38
+ * with the RequestContext base.
39
+ */
40
+ export declare function systemInstructionsRule(text: string): Record<string, unknown>;
41
+ /** Text of the frozen system-instructions rule, if the context carries one. */
42
+ export declare function systemInstructionsRuleText(context: Record<string, unknown>): string | undefined;
43
+ /**
44
+ * Return `context` with the system-instructions rule applied, or the same
45
+ * object when nothing changes (see `SystemInstructions.authoritative`).
46
+ */
47
+ export declare function withSystemInstructions(context: Record<string, unknown>, instructions: SystemInstructions | undefined): Record<string, unknown>;
48
+ export declare const DYNAMIC_REQUEST_CONTEXT_KEYS: readonly ["tools", "custom_subagents", "mcp_file_system_options", "mcp_meta_tool_options", "web_search_enabled", "web_fetch_enabled", "custom_subagents_info_complete", "mcp_file_system_info_complete", "mcp_info_complete", "hooks_additional_context"];
16
49
  export type DynamicRequestContextKey = typeof DYNAMIC_REQUEST_CONTEXT_KEYS[number];
50
+ /**
51
+ * The host system context (delivered as the system-instructions rule) already
52
+ * carries these; never keep them on a frozen base.
53
+ */
54
+ export declare const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS: readonly ["agent_skills", "agent_skills_info_complete"];
17
55
  /**
18
56
  * Full RequestContext payload for live UMA + exec #10 reply.
19
- * Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
20
- * Honors `instructions` globs the same way OpenCode does. The provider never
21
- * looks in Cursor's own directories; a `.cursor/` path is read only when the
22
- * user's own OpenCode config lists it, exactly as the host would read it.
57
+ * Workspace env/git/layout, the host system-instructions rule, and
58
+ * host-advertised tools and subagents. The provider never looks in Cursor's
59
+ * own directories.
23
60
  */
24
61
  export declare function buildRequestContext(input: BuildRequestContextInput): Promise<Record<string, unknown>>;
25
62
  /** Rediscover only capability/plugin sections that may change during a chat. */
@@ -1,8 +1,6 @@
1
1
  import path from "node:path";
2
- import { extractHostSubagentCatalog, toolsToDescriptors, toolsToMcpDescriptors, } from "../protocol/tools.js";
3
- import { collectRules, findGitWorktree, loadMergedConfig, } from "./rules.js";
4
- import { collectSkills } from "./skills.js";
5
- import { collectAgents } from "./agents.js";
2
+ import { extractHostSubagentCatalog, toolsToMcpDescriptors, } from "../protocol/tools.js";
3
+ import { loadMergedConfig, } from "./rules.js";
6
4
  import { collectPlugins } from "./plugins.js";
7
5
  import { collectGit } from "./git.js";
8
6
  import { collectProjectLayout } from "./layout.js";
@@ -10,75 +8,108 @@ import { buildEnv } from "./env.js";
10
8
  import { ensureOpencodeProjectDir } from "./paths.js";
11
9
  import { holdCapabilityOverlay } from "./overlay.js";
12
10
  import { traceRequestContextPaths } from "../debug.js";
11
+ /** Rule name Cursor shows for the host system context (not a file). */
12
+ export const SYSTEM_INSTRUCTIONS_RULE_PATH = "OpenCode system instructions";
13
+ /**
14
+ * Cursor does not follow a client-seeded `system` message, and applies a rule
15
+ * only by its type. The Cursor CLI sends AGENTS.md as `alwaysApply`
16
+ * (`CursorRuleType.global`) and the IDE ships its own guidance as a non-file
17
+ * global rule; the host system context goes out the same way, once, frozen
18
+ * with the RequestContext base.
19
+ */
20
+ export function systemInstructionsRule(text) {
21
+ return {
22
+ full_path: SYSTEM_INSTRUCTIONS_RULE_PATH,
23
+ content: text,
24
+ type: { global: {} },
25
+ };
26
+ }
27
+ function isSystemInstructionsRule(rule) {
28
+ if (!rule || typeof rule !== "object")
29
+ return false;
30
+ const record = rule;
31
+ const type = record.type;
32
+ return record.full_path === SYSTEM_INSTRUCTIONS_RULE_PATH && !!type?.global
33
+ && typeof record.content === "string";
34
+ }
35
+ /** Text of the frozen system-instructions rule, if the context carries one. */
36
+ export function systemInstructionsRuleText(context) {
37
+ const rules = Array.isArray(context.rules) ? context.rules : [];
38
+ const rule = rules.find(isSystemInstructionsRule);
39
+ return rule ? rule.content : undefined;
40
+ }
41
+ /**
42
+ * Return `context` with the system-instructions rule applied, or the same
43
+ * object when nothing changes (see `SystemInstructions.authoritative`).
44
+ */
45
+ export function withSystemInstructions(context, instructions) {
46
+ const text = instructions?.text.trim() ? instructions.text : undefined;
47
+ if (!text)
48
+ return context;
49
+ const current = systemInstructionsRuleText(context);
50
+ if (current !== undefined && (current === text || !instructions.authoritative))
51
+ return context;
52
+ return { ...context, rules: [systemInstructionsRule(text)] };
53
+ }
54
+ /** Keep only the system-instructions rule; older bases carried untyped rules Cursor never applied. */
55
+ function keepSystemInstructionsRule(context) {
56
+ if (!Object.hasOwn(context, "rules"))
57
+ return;
58
+ const rules = Array.isArray(context.rules) ? context.rules.filter(isSystemInstructionsRule) : [];
59
+ if (rules.length > 0)
60
+ context.rules = rules.slice(0, 1);
61
+ else
62
+ delete context.rules;
63
+ }
13
64
  export const DYNAMIC_REQUEST_CONTEXT_KEYS = [
14
65
  "tools",
15
- "agent_skills",
16
66
  "custom_subagents",
17
67
  "mcp_file_system_options",
18
68
  "mcp_meta_tool_options",
19
69
  "web_search_enabled",
20
70
  "web_fetch_enabled",
21
- "agent_skills_info_complete",
22
71
  "custom_subagents_info_complete",
23
72
  "mcp_file_system_info_complete",
24
73
  "mcp_info_complete",
25
74
  "hooks_additional_context",
26
75
  ];
27
- const DEFAULT_HOST_SUBAGENTS = [
28
- {
29
- name: "general",
30
- description: "General-purpose agent for complex research and multi-step tasks.",
31
- },
32
- {
33
- name: "explore",
34
- description: "Read-only agent for searching and understanding the local codebase.",
35
- },
76
+ /**
77
+ * The host system context (delivered as the system-instructions rule) already
78
+ * carries these; never keep them on a frozen base.
79
+ */
80
+ export const HOST_DUPLICATED_REQUEST_CONTEXT_KEYS = [
81
+ "agent_skills",
82
+ "agent_skills_info_complete",
36
83
  ];
37
84
  /**
38
- * Convert the raw host executor catalog into the exact subagent list advertised
39
- * to Cursor. `hostSubagents.complete` answers a narrow question: did the host's
40
- * task tool itself exposes an exhaustive recipient list (schema enum or
41
- * catalog marker)? OpenCode's task tool intentionally uses a plain string, so
42
- * that raw flag is false even though we can still advertise a complete Cursor
43
- * catalog by adding built-in defaults and discovered agent files.
44
- *
45
- * `custom_subagents_info_complete` must describe the final advertised list, not
46
- * the raw extraction source. If there is no executor, the empty list is
47
- * complete. If the host catalog is complete, use it verbatim. Otherwise we make
48
- * the advertised set complete by augmenting with default host agents and all
49
- * locally discovered agents.
85
+ * Advertise only what the host `task` / `subagent` catalog already named.
50
86
  */
51
- function buildAdvertisedSubagentCatalog(hostSubagents, discoveredAgents) {
87
+ function buildAdvertisedSubagentCatalog(hostSubagents) {
52
88
  if (!hostSubagents.executor)
53
89
  return { agents: [], complete: true };
54
- if (hostSubagents.complete)
55
- return { agents: hostSubagents.agents, complete: true };
56
- return {
57
- agents: [...DEFAULT_HOST_SUBAGENTS, ...hostSubagents.agents, ...discoveredAgents],
58
- complete: true,
59
- };
90
+ return { agents: hostSubagents.agents, complete: hostSubagents.complete };
91
+ }
92
+ function stripHostDuplicatedRequestContextFields(context) {
93
+ for (const key of HOST_DUPLICATED_REQUEST_CONTEXT_KEYS)
94
+ delete context[key];
95
+ keepSystemInstructionsRule(context);
60
96
  }
61
97
  /**
62
98
  * Full RequestContext payload for live UMA + exec #10 reply.
63
- * Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
64
- * Honors `instructions` globs the same way OpenCode does. The provider never
65
- * looks in Cursor's own directories; a `.cursor/` path is read only when the
66
- * user's own OpenCode config lists it, exactly as the host would read it.
99
+ * Workspace env/git/layout, the host system-instructions rule, and
100
+ * host-advertised tools and subagents. The provider never looks in Cursor's
101
+ * own directories.
67
102
  */
68
103
  export async function buildRequestContext(input) {
69
104
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
70
- const { rules, config, worktree } = await collectRules(workspaceRoot, input.mergedConfig);
105
+ const config = input.mergedConfig ?? await loadMergedConfig(workspaceRoot);
71
106
  const [dynamic, git, layout] = await Promise.all([
72
- buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config),
107
+ buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config),
73
108
  collectGit(workspaceRoot),
74
109
  collectProjectLayout(workspaceRoot),
75
110
  ]);
76
- const base = {
111
+ const workspace = {
77
112
  env: buildEnv(workspaceRoot),
78
- rules: rules.map((r) => ({
79
- full_path: r.fullPath,
80
- content: r.content,
81
- })),
82
113
  repository_info: git.repositoryInfo,
83
114
  git_repos: git.gitRepos,
84
115
  project_layouts: [layout],
@@ -88,47 +119,25 @@ export async function buildRequestContext(input) {
88
119
  git_repo_info_complete: true,
89
120
  git_status_info_complete: true,
90
121
  };
122
+ const base = withSystemInstructions(workspace, input.systemInstructions);
91
123
  const ctx = materializeRequestContext(base, dynamic);
92
124
  traceRequestContextPaths("buildRequestContext", ctx);
93
125
  return ctx;
94
126
  }
95
- async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config) {
127
+ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config) {
96
128
  const providerIdentifier = input.providerIdentifier ?? "opencode";
97
129
  const tools = input.tools ?? [];
98
- const [skills, agents, plugins] = await Promise.all([
99
- collectSkills(workspaceRoot, worktree),
100
- collectAgents(workspaceRoot),
101
- collectPlugins(workspaceRoot, config),
102
- ]);
130
+ const plugins = await collectPlugins(workspaceRoot, config);
103
131
  const mcpServerNames = Object.keys(config.mcp ?? {});
104
- const flat = toolsToDescriptors(tools, providerIdentifier, mcpServerNames);
105
- const nested = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames);
132
+ const slim = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames, { namesOnly: true });
106
133
  const projectDir = ensureOpencodeProjectDir(workspaceRoot);
107
134
  const hostSubagents = extractHostSubagentCatalog(tools);
108
- const advertisedSubagents = buildAdvertisedSubagentCatalog(hostSubagents, agents);
109
- const discoveredByName = new Map(agents.map((agent) => [agent.name, agent]));
110
- const advertisedByName = new Map();
111
- for (const agent of advertisedSubagents.agents) {
112
- if (!advertisedByName.has(agent.name))
113
- advertisedByName.set(agent.name, agent);
114
- }
115
- const customSubagents = [...advertisedByName.values()].map((agent) => {
116
- const discovered = discoveredByName.get(agent.name);
117
- return {
118
- full_path: discovered?.fullPath ?? "",
119
- name: agent.name,
120
- description: discovered?.description || agent.description || "Host-configured subagent.",
121
- // The host applies the real configured prompt when Task/Actor executes.
122
- // Cursor only needs enough context to select the recipient intentionally.
123
- prompt: discovered?.prompt ||
124
- `Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
125
- };
126
- });
127
- const liveSkills = skills.map((s) => ({
128
- id: s.name,
129
- full_path: s.fullPath,
130
- content: s.content,
131
- description: s.description,
135
+ const advertisedSubagents = buildAdvertisedSubagentCatalog(hostSubagents);
136
+ const customSubagents = advertisedSubagents.agents.map((agent) => ({
137
+ full_path: "",
138
+ name: agent.name,
139
+ description: agent.description || "Host-configured subagent.",
140
+ prompt: `Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
132
141
  }));
133
142
  const livePlugins = plugins.map((p) => ({
134
143
  id: p.id,
@@ -136,24 +145,19 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
136
145
  }));
137
146
  const overlay = input.conversationId
138
147
  ? holdCapabilityOverlay(input.conversationId, {
139
- skills: liveSkills,
140
148
  subagents: customSubagents,
141
149
  plugins: livePlugins,
142
150
  })
143
- : { skills: liveSkills.map(({ full_path, content, description }) => ({ full_path, content, description })), subagents: customSubagents, plugins: livePlugins };
151
+ : { subagents: customSubagents, plugins: livePlugins };
144
152
  const dynamic = {
145
- tools: flat,
146
- agent_skills: overlay.skills,
147
- custom_subagents: overlay.subagents,
148
153
  mcp_file_system_options: {
149
154
  enabled: true,
150
155
  // Cursor metadata root (mcps / agent-tools), not the git workspace.
151
156
  workspace_project_dir: projectDir,
152
- mcp_descriptors: nested,
153
157
  },
154
158
  mcp_meta_tool_options: {
155
159
  enabled: true,
156
- mcp_descriptors: nested,
160
+ ...(slim.length > 0 ? { mcp_descriptors: slim } : {}),
157
161
  },
158
162
  // This provider always rejects native web_search/web_fetch interaction
159
163
  // queries with a headless-UI reason (see interactions.ts). Advertise that
@@ -161,11 +165,12 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
161
165
  // custom_web* aliases instead of routing through a query doomed to fail.
162
166
  web_search_enabled: false,
163
167
  web_fetch_enabled: false,
164
- agent_skills_info_complete: true,
165
168
  custom_subagents_info_complete: advertisedSubagents.complete,
166
169
  mcp_file_system_info_complete: true,
167
170
  mcp_info_complete: true,
168
171
  };
172
+ if (overlay.subagents.length > 0)
173
+ dynamic.custom_subagents = overlay.subagents;
169
174
  if (overlay.plugins.length > 0) {
170
175
  dynamic.hooks_additional_context = overlay.plugins.map((p) => p.line).join("\n");
171
176
  }
@@ -174,17 +179,15 @@ async function buildDynamicRequestContextFromDiscovery(input, workspaceRoot, wor
174
179
  /** Rediscover only capability/plugin sections that may change during a chat. */
175
180
  export async function buildDynamicRequestContext(input) {
176
181
  const workspaceRoot = path.resolve(input.workspaceRoot || process.cwd());
177
- const [worktree, config] = await Promise.all([
178
- findGitWorktree(workspaceRoot),
179
- input.mergedConfig
180
- ? Promise.resolve(input.mergedConfig)
181
- : loadMergedConfig(workspaceRoot),
182
- ]);
183
- return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, worktree, config);
182
+ const config = input.mergedConfig
183
+ ? input.mergedConfig
184
+ : await loadMergedConfig(workspaceRoot);
185
+ return buildDynamicRequestContextFromDiscovery(input, workspaceRoot, config);
184
186
  }
185
187
  /** Keep expensive workspace state frozen while replacing every live capability field. */
186
188
  export function materializeRequestContext(base, dynamic) {
187
189
  const context = structuredClone(base);
190
+ stripHostDuplicatedRequestContextFields(context);
188
191
  for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS)
189
192
  delete context[key];
190
193
  for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS) {
@@ -196,6 +199,7 @@ export function materializeRequestContext(base, dynamic) {
196
199
  /** Strip live capability fields before retaining/persisting a conversation base. */
197
200
  export function requestContextBase(context) {
198
201
  const base = structuredClone(context);
202
+ stripHostDuplicatedRequestContextFields(base);
199
203
  for (const key of DYNAMIC_REQUEST_CONTEXT_KEYS)
200
204
  delete base[key];
201
205
  return base;