cursor-opencode-provider 0.2.9 → 0.3.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 (42) hide show
  1. package/README.md +128 -8
  2. package/dist/auth.d.ts +7 -0
  3. package/dist/auth.js +9 -0
  4. package/dist/compaction-marker.d.ts +14 -0
  5. package/dist/compaction-marker.js +36 -0
  6. package/dist/context/env.js +6 -2
  7. package/dist/index.d.ts +1 -3
  8. package/dist/index.js +2 -8
  9. package/dist/language-model.js +37 -33
  10. package/dist/model-config.d.ts +15 -0
  11. package/dist/model-config.js +188 -0
  12. package/dist/opencode2/catalog.d.ts +33 -0
  13. package/dist/opencode2/catalog.js +85 -0
  14. package/dist/opencode2/integration.d.ts +23 -0
  15. package/dist/opencode2/integration.js +124 -0
  16. package/dist/opencode2/types.d.ts +316 -0
  17. package/dist/opencode2/types.js +18 -0
  18. package/dist/plugin-core.d.ts +27 -0
  19. package/dist/plugin-core.js +31 -0
  20. package/dist/plugin-opencode2.d.ts +3 -0
  21. package/dist/plugin-opencode2.js +293 -0
  22. package/dist/plugin-v2.d.ts +7 -0
  23. package/dist/plugin-v2.js +1 -15
  24. package/dist/plugin.d.ts +1 -15
  25. package/dist/plugin.js +11 -191
  26. package/dist/protocol/messages.d.ts +10 -0
  27. package/dist/protocol/messages.js +56 -0
  28. package/dist/protocol/tools.d.ts +8 -1
  29. package/dist/protocol/tools.js +9 -4
  30. package/dist/session-directory.d.ts +17 -0
  31. package/dist/session-directory.js +35 -0
  32. package/dist/session.d.ts +13 -1
  33. package/dist/session.js +70 -1
  34. package/dist/shell-timeout.d.ts +3 -1
  35. package/dist/shell-timeout.js +6 -2
  36. package/dist/usage.d.ts +30 -0
  37. package/dist/usage.js +77 -0
  38. package/dist/web-search-tool.d.ts +33 -0
  39. package/dist/web-search-tool.js +22 -0
  40. package/dist/web-tools.d.ts +9 -25
  41. package/dist/web-tools.js +34 -36
  42. package/package.json +8 -3
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Local structural types for the OpenCode 2.0 beta plugin API.
3
+ *
4
+ * Why not import from `@opencode-ai/plugin`?
5
+ * • The 2.0 types live on the `@next` dist-tag (`0.0.0-next-*`), which cannot
6
+ * coexist with this package's existing `@opencode-ai/plugin@^1.17.13`
7
+ * dependency under the same module specifier.
8
+ * • 2.0 is beta and its schema churns; a hand-maintained subset of only what
9
+ * we touch is more stable than pinning a moving target.
10
+ *
11
+ * These mirror `@opencode-ai/plugin@next` `dist/promise/*.d.ts` as of
12
+ * `0.0.0-next-16281`. `test/opencode2-conformance.test-d.ts` asserts our
13
+ * default export still satisfies the real 2.0 `Plugin` interface.
14
+ *
15
+ * Effect `Schema` brands (Provider.ID, Model.ID, …) are modelled as plain
16
+ * `string`; brands are compile-time only and erase at runtime.
17
+ */
18
+ export type Registration = {
19
+ readonly dispose: () => Promise<void>;
20
+ };
21
+ export type Hooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void) => Promise<Registration>;
22
+ export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>;
23
+ export type ProviderInfo = {
24
+ id: string;
25
+ name: string;
26
+ /** `"aisdk:<pkg>"` selects the AI SDK path; a bare specifier selects native. */
27
+ package: string;
28
+ integrationID?: string;
29
+ disabled?: boolean;
30
+ settings?: Record<string, any>;
31
+ headers?: Record<string, string>;
32
+ body?: Record<string, any>;
33
+ };
34
+ export type ModelVariantInfo = {
35
+ id: string;
36
+ settings?: Record<string, any>;
37
+ headers?: Record<string, string>;
38
+ body?: Record<string, any>;
39
+ };
40
+ export type ModelInfo2 = {
41
+ id: string;
42
+ /** Wire id sent to the provider. Lets one Cursor model back several entries. */
43
+ modelID: string;
44
+ providerID: string;
45
+ name: string;
46
+ family?: string;
47
+ capabilities: {
48
+ tools: boolean;
49
+ input: readonly string[];
50
+ output: readonly string[];
51
+ };
52
+ variants: readonly ModelVariantInfo[];
53
+ time: {
54
+ released: number;
55
+ };
56
+ cost: readonly unknown[];
57
+ status: "alpha" | "beta" | "deprecated" | "active";
58
+ enabled: boolean;
59
+ limit: {
60
+ context: number;
61
+ input?: number;
62
+ output: number;
63
+ };
64
+ settings?: Record<string, any>;
65
+ headers?: Record<string, string>;
66
+ body?: Record<string, any>;
67
+ package?: string;
68
+ };
69
+ export type CredentialOAuth = {
70
+ type: "oauth";
71
+ methodID: string;
72
+ refresh: string;
73
+ access: string;
74
+ expires: number;
75
+ metadata?: Record<string, unknown>;
76
+ };
77
+ export type CredentialKey = {
78
+ type: "key";
79
+ key: string;
80
+ metadata?: Record<string, unknown>;
81
+ };
82
+ export type CredentialValue = CredentialOAuth | CredentialKey;
83
+ export type ConnectionInfo = {
84
+ type: "credential";
85
+ id: string;
86
+ label: string;
87
+ } | {
88
+ type: "env";
89
+ name: string;
90
+ };
91
+ /**
92
+ * Read accessors we never call are intentionally loose (`any`). Modelling the
93
+ * host's Effect-schema records exactly would make the conformance guard churn on
94
+ * unrelated upstream edits without protecting anything we depend on. The
95
+ * `update` signatures — the ones we actually build the catalog with — stay precise.
96
+ */
97
+ export type CatalogDraft = {
98
+ readonly provider: {
99
+ list(): readonly any[];
100
+ get(providerID: string): any;
101
+ /** Upsert: creates the provider when absent. */
102
+ update(providerID: string, update: (provider: ProviderInfo) => void): void;
103
+ remove(providerID: string): void;
104
+ };
105
+ readonly model: {
106
+ get(providerID: string, modelID: string): any;
107
+ /** Upsert: creates provider and/or model when absent. */
108
+ update(providerID: string, modelID: string, update: (model: ModelInfo2) => void): void;
109
+ remove(providerID: string, modelID: string): void;
110
+ readonly default: {
111
+ get(): {
112
+ providerID: string;
113
+ modelID: string;
114
+ } | undefined;
115
+ set(providerID: string, modelID: string): void;
116
+ };
117
+ };
118
+ };
119
+ export type CatalogDomain = {
120
+ readonly transform: Transform<CatalogDraft>;
121
+ readonly reload: () => Promise<void>;
122
+ };
123
+ export type IntegrationTextPrompt = {
124
+ type: "text";
125
+ key: string;
126
+ message: string;
127
+ placeholder?: string;
128
+ };
129
+ export type IntegrationOAuthMethod = {
130
+ id: string;
131
+ type: "oauth";
132
+ label: string;
133
+ prompts?: IntegrationTextPrompt[];
134
+ };
135
+ export type IntegrationKeyMethod = {
136
+ type: "key";
137
+ label?: string;
138
+ };
139
+ export type IntegrationEnvMethod = {
140
+ type: "env";
141
+ names: string[];
142
+ };
143
+ export type IntegrationMethod = IntegrationOAuthMethod | IntegrationKeyMethod | IntegrationEnvMethod;
144
+ export type IntegrationOAuthAuthorization = {
145
+ readonly url: string;
146
+ readonly instructions: string;
147
+ readonly expiresAt?: number;
148
+ } & ({
149
+ readonly mode: "auto";
150
+ readonly callback: Promise<CredentialOAuth>;
151
+ } | {
152
+ readonly mode: "code";
153
+ readonly callback: (code: string) => Promise<CredentialOAuth>;
154
+ });
155
+ export type IntegrationOAuthMethodRegistration = {
156
+ readonly integrationID: string;
157
+ readonly method: IntegrationOAuthMethod;
158
+ readonly authorize: (inputs: Record<string, string>) => Promise<IntegrationOAuthAuthorization>;
159
+ readonly refresh?: (credential: CredentialOAuth) => Promise<CredentialOAuth>;
160
+ readonly label?: (credential: CredentialOAuth) => string | undefined;
161
+ };
162
+ export type IntegrationMethodRegistration = IntegrationOAuthMethodRegistration | {
163
+ readonly integrationID: string;
164
+ readonly method: IntegrationKeyMethod;
165
+ } | {
166
+ readonly integrationID: string;
167
+ readonly method: IntegrationEnvMethod;
168
+ };
169
+ export type IntegrationRef = {
170
+ id: string;
171
+ name: string;
172
+ };
173
+ export type IntegrationDraft = {
174
+ list(): readonly any[];
175
+ get(id: string): any;
176
+ update(id: string, update: (integration: IntegrationRef) => void): void;
177
+ remove(id: string): void;
178
+ readonly method: {
179
+ list(integrationID: string): readonly any[];
180
+ update(input: IntegrationMethodRegistration): void;
181
+ remove(integrationID: string, method: any): void;
182
+ };
183
+ };
184
+ export type IntegrationDomain = {
185
+ readonly transform: Transform<IntegrationDraft>;
186
+ readonly reload: () => Promise<void>;
187
+ readonly connection: {
188
+ readonly active: (integrationID: string) => Promise<ConnectionInfo | undefined>;
189
+ readonly resolve: (connection: ConnectionInfo) => Promise<CredentialValue | undefined>;
190
+ };
191
+ };
192
+ export type AISDKHooks = {
193
+ sdk: {
194
+ readonly model: ModelInfo2;
195
+ readonly package: string;
196
+ readonly options: Record<string, any>;
197
+ sdk?: any;
198
+ };
199
+ language: {
200
+ readonly model: ModelInfo2;
201
+ readonly sdk: any;
202
+ readonly options: Record<string, any>;
203
+ language?: unknown;
204
+ };
205
+ };
206
+ export type AISDKDomain = {
207
+ readonly hook: Hooks<AISDKHooks>;
208
+ };
209
+ export type ToolDefinition = {
210
+ readonly name: string;
211
+ readonly description: string;
212
+ readonly input: unknown;
213
+ readonly output?: unknown;
214
+ readonly execute: (input: any, context: any) => Promise<any>;
215
+ };
216
+ export type ToolDraft = {
217
+ add(tool: ToolDefinition): void;
218
+ };
219
+ export type ToolHookBase = {
220
+ readonly tool: string;
221
+ readonly sessionID: string;
222
+ readonly agent: string;
223
+ readonly messageID: string;
224
+ readonly callID: string;
225
+ };
226
+ export type ToolHooks = {
227
+ readonly "execute.before": ToolHookBase & {
228
+ input: unknown;
229
+ };
230
+ readonly "execute.after": ToolHookBase & {
231
+ readonly input: unknown;
232
+ } & ({
233
+ readonly status: "completed";
234
+ result: any;
235
+ } | {
236
+ readonly status: "error";
237
+ error: any;
238
+ });
239
+ };
240
+ export type ToolDomain = {
241
+ readonly transform: Transform<ToolDraft>;
242
+ readonly hook: Hooks<ToolHooks>;
243
+ };
244
+ export type SessionContext = {
245
+ readonly sessionID: string;
246
+ readonly agent: string;
247
+ readonly model: {
248
+ providerID: string;
249
+ id: string;
250
+ variant?: string;
251
+ };
252
+ system: Array<any>;
253
+ messages: Array<any>;
254
+ tools: Record<string, {
255
+ description: string;
256
+ input: unknown;
257
+ }>;
258
+ };
259
+ export type SessionHooks = {
260
+ readonly context: SessionContext;
261
+ };
262
+ /** Only the `location.directory` field we actually read; the rest of the real `SessionInfo` is loose. */
263
+ export type SessionInfo = {
264
+ readonly id: string;
265
+ readonly location: {
266
+ readonly directory: string;
267
+ };
268
+ };
269
+ export type SessionDomain = {
270
+ readonly hook: Hooks<SessionHooks>;
271
+ readonly get: (input: {
272
+ sessionID: string;
273
+ }) => Promise<SessionInfo>;
274
+ };
275
+ export type WebSearchDefinition = {
276
+ readonly id: string;
277
+ readonly name: string;
278
+ readonly execute: (input: any, context: {
279
+ readonly signal: AbortSignal;
280
+ }) => Promise<readonly any[]>;
281
+ };
282
+ export type WebSearchDraft = {
283
+ add(definition: WebSearchDefinition): void;
284
+ readonly default: {
285
+ get(): string | undefined;
286
+ set(providerID: string): void;
287
+ };
288
+ };
289
+ export type WebSearchDomain = {
290
+ readonly transform: Transform<WebSearchDraft>;
291
+ readonly reload: () => Promise<void>;
292
+ };
293
+ export type EventDomain = {
294
+ readonly subscribe: (...args: any[]) => any;
295
+ };
296
+ export type App = {
297
+ readonly name: string;
298
+ readonly version: string;
299
+ readonly channel: string;
300
+ };
301
+ export type PluginContext = {
302
+ readonly app: App;
303
+ readonly options: Readonly<Record<string, any>>;
304
+ readonly aisdk: AISDKDomain;
305
+ readonly catalog: CatalogDomain;
306
+ readonly event: EventDomain;
307
+ readonly integration: IntegrationDomain;
308
+ readonly session: SessionDomain;
309
+ readonly tool: ToolDomain;
310
+ readonly websearch: WebSearchDomain;
311
+ };
312
+ export type Cleanup = () => Promise<void> | void;
313
+ export type Plugin2 = {
314
+ readonly id: string;
315
+ readonly setup: (context: PluginContext) => Promise<Cleanup | void> | Cleanup | void;
316
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Local structural types for the OpenCode 2.0 beta plugin API.
3
+ *
4
+ * Why not import from `@opencode-ai/plugin`?
5
+ * • The 2.0 types live on the `@next` dist-tag (`0.0.0-next-*`), which cannot
6
+ * coexist with this package's existing `@opencode-ai/plugin@^1.17.13`
7
+ * dependency under the same module specifier.
8
+ * • 2.0 is beta and its schema churns; a hand-maintained subset of only what
9
+ * we touch is more stable than pinning a moving target.
10
+ *
11
+ * These mirror `@opencode-ai/plugin@next` `dist/promise/*.d.ts` as of
12
+ * `0.0.0-next-16281`. `test/opencode2-conformance.test-d.ts` asserts our
13
+ * default export still satisfies the real 2.0 `Plugin` interface.
14
+ *
15
+ * Effect `Schema` brands (Provider.ID, Model.ID, …) are modelled as plain
16
+ * `string`; brands are compile-time only and erase at runtime.
17
+ */
18
+ export {};
@@ -0,0 +1,27 @@
1
+ import { createCursorLanguageModel } from "./language-model.js";
2
+ import type { CreateCursorOptions } from "./index.js";
3
+ /**
4
+ * Runtime-agnostic pieces shared by every plugin surface:
5
+ * • classic Hooks plugin (`plugin.ts`)
6
+ * • OpenCode 1.18 v2 plugin (`plugin-v2.ts`)
7
+ * • OpenCode 2.0 beta plugin (`plugin-opencode2.ts`)
8
+ *
9
+ * Nothing here may import a host plugin API — the 1.18 and 2.0 APIs are
10
+ * source-incompatible, so anything host-shaped belongs in the entrypoint.
11
+ */
12
+ /** Minimal AI SDK provider surface OpenCode expects from an `aisdk` package. */
13
+ export type CursorSdk = {
14
+ languageModel(modelId: string): ReturnType<typeof createCursorLanguageModel>;
15
+ };
16
+ export declare function createSdk(options: CreateCursorOptions): CursorSdk;
17
+ /**
18
+ * Whether an OpenCode `aisdk` package string refers to this provider.
19
+ *
20
+ * Matches on the provider id first (the common case once the catalog entry is
21
+ * ours), then on the package specifier so a bare `cursor-opencode-provider`,
22
+ * an `aisdk:`-prefixed form, or a resolved `dist/index.js` file URL all hit.
23
+ */
24
+ export declare function isCursorPackage(pkg: string, providerID: string): boolean;
25
+ /** API base for auth, model discovery, and GetServerConfig. */
26
+ export declare function cursorApiBaseURL(): string;
27
+ export declare function cursorGetServerConfigTelemetryEnabled(): boolean;
@@ -0,0 +1,31 @@
1
+ import { CURSOR_API_HOST, CURSOR_PROVIDER_ID } from "./shared.js";
2
+ import { createCursorLanguageModel } from "./language-model.js";
3
+ export function createSdk(options) {
4
+ const providerId = options.name || CURSOR_PROVIDER_ID;
5
+ return {
6
+ languageModel(modelId) {
7
+ return createCursorLanguageModel(modelId, providerId, options);
8
+ },
9
+ };
10
+ }
11
+ /**
12
+ * Whether an OpenCode `aisdk` package string refers to this provider.
13
+ *
14
+ * Matches on the provider id first (the common case once the catalog entry is
15
+ * ours), then on the package specifier so a bare `cursor-opencode-provider`,
16
+ * an `aisdk:`-prefixed form, or a resolved `dist/index.js` file URL all hit.
17
+ */
18
+ export function isCursorPackage(pkg, providerID) {
19
+ if (providerID === CURSOR_PROVIDER_ID)
20
+ return true;
21
+ return (pkg.includes("cursor-opencode-provider") ||
22
+ /cursor-opencode-provider[/\\]dist[/\\]index\.js/.test(pkg));
23
+ }
24
+ /** API base for auth, model discovery, and GetServerConfig. */
25
+ export function cursorApiBaseURL() {
26
+ return process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
27
+ }
28
+ export function cursorGetServerConfigTelemetryEnabled() {
29
+ return (process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "1" ||
30
+ process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
31
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin2 } from "./opencode2/types.js";
2
+ declare const plugin: Plugin2;
3
+ export default plugin;
@@ -0,0 +1,293 @@
1
+ import { CURSOR_PROVIDER_ID } from "./shared.js";
2
+ import { createSdk, cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled, isCursorPackage } from "./plugin-core.js";
3
+ import { adoptCompatHostCacheDir, opencodeGlobalCacheDir } from "./context/paths.js";
4
+ import { discoverModels, isCacheFresh, readCache } from "./models.js";
5
+ import { resolveAgentUrl } from "./agent-url.js";
6
+ import { sessionActivity } from "./activity.js";
7
+ import { fetchOpenCodeWebSearchText } from "./web-tools.js";
8
+ import { captureCursorShellResult, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
+ import { applyCursorModels, applyCursorProvider } from "./opencode2/catalog.js";
10
+ import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
11
+ import { markCompactionSession } from "./compaction-marker.js";
12
+ import { markSessionDirectory } from "./session-directory.js";
13
+ /**
14
+ * OpenCode 2.0 beta plugin.
15
+ *
16
+ * Separate from `plugin-v2.ts` on purpose: the OpenCode 1.18 `/v2/promise` API
17
+ * and the 2.0 API are source-incompatible (hook signatures, OAuth value type,
18
+ * provider schema), so they cannot share an entrypoint. Shared behavior lives in
19
+ * `plugin-core.ts`, `model-config.ts`, and `opencode2/*`.
20
+ *
21
+ * Load with: { "plugins": ["cursor-opencode-provider/plugin/opencode2"] }
22
+ */
23
+ async function loadModels(cacheDir, accessToken) {
24
+ const cached = await readCache(cacheDir);
25
+ if (cached?.models.length && isCacheFresh(cached))
26
+ return cached.models;
27
+ if (accessToken) {
28
+ try {
29
+ return await discoverModels(accessToken, cacheDir, { baseURL: cursorApiBaseURL() });
30
+ }
31
+ catch {
32
+ // Fall through to stale-on-failure below.
33
+ }
34
+ }
35
+ // Preserve offline / stale-cache behavior rather than emptying the picker.
36
+ return cached?.models ?? [];
37
+ }
38
+ const plugin = {
39
+ id: "cursor.provider",
40
+ setup: async (ctx) => {
41
+ // Prefer OCP HostProfile.cacheDir when present; else XDG host heuristic.
42
+ await adoptCompatHostCacheDir();
43
+ const cacheDir = opencodeGlobalCacheDir();
44
+ const registrations = [];
45
+ const track = async (p) => {
46
+ registrations.push(await p);
47
+ };
48
+ let models = [];
49
+ // ── Credentials ──────────────────────────────────────────────────────────
50
+ await track(ctx.integration.transform(applyCursorIntegration));
51
+ let cachedToken;
52
+ let tokenInflight;
53
+ /**
54
+ * Cache only a *successful* resolution.
55
+ *
56
+ * On a fresh install `setup()` runs before the user has connected, so the
57
+ * first attempt necessarily returns nothing. Memoizing that would pin the
58
+ * plugin to "no credentials" for the whole process and models would never
59
+ * load, even after a successful /connect.
60
+ */
61
+ const accessToken = async () => {
62
+ if (cachedToken)
63
+ return cachedToken;
64
+ tokenInflight ??= resolveCursorAccessToken(ctx.integration).finally(() => {
65
+ tokenInflight = undefined;
66
+ });
67
+ const token = await tokenInflight;
68
+ if (token)
69
+ cachedToken = token;
70
+ return token;
71
+ };
72
+ // ── Catalog ──────────────────────────────────────────────────────────────
73
+ // Runs now with whatever models we have (likely none) and is replayed by the
74
+ // host on every `catalog.reload()`, so the async fill below just re-triggers it.
75
+ await track(ctx.catalog.transform((draft) => {
76
+ applyCursorProvider(draft);
77
+ applyCursorModels(draft, models);
78
+ }));
79
+ // ── AI SDK wiring ────────────────────────────────────────────────────────
80
+ await track(ctx.aisdk.hook("sdk", async (event) => {
81
+ if (event.sdk)
82
+ return;
83
+ if (!isCursorPackage(event.package, event.model.providerID))
84
+ return;
85
+ const token = await accessToken();
86
+ event.sdk = createSdk({
87
+ name: event.model.providerID || CURSOR_PROVIDER_ID,
88
+ ...(token ? { accessToken: token } : {}),
89
+ // Static fallback only. This hook fires once per model/package, not
90
+ // per session, and 2.0 runs one daemon across many projects — the
91
+ // real per-request directory comes from the session.context hook
92
+ // below via `getSessionDirectory`, which `language-model.ts` prefers.
93
+ workspaceRoot: process.cwd(),
94
+ cacheDir,
95
+ ...event.options,
96
+ });
97
+ }));
98
+ await track(ctx.aisdk.hook("language", (event) => {
99
+ if (event.language)
100
+ return;
101
+ if (event.model.providerID !== CURSOR_PROVIDER_ID)
102
+ return;
103
+ if (typeof event.sdk?.languageModel !== "function")
104
+ return;
105
+ // `modelID` is the Cursor wire id; `id` may be a synthetic long-context entry.
106
+ event.language = event.sdk.languageModel(event.model.modelID || event.model.id);
107
+ }));
108
+ // ── Web search ───────────────────────────────────────────────────────────
109
+ // Registered as a tool, matching the classic plugin. 2.0 also has a
110
+ // first-class `websearch` domain, but its Result shape is structured
111
+ // ({url, title?, content?}) while the Exa backend here returns an opaque
112
+ // text blob — mapping one to the other would mean guessing Exa's payload
113
+ // shape, so the tool form stays until that can be verified against a live
114
+ // response.
115
+ await track(ctx.tool.transform((draft) => {
116
+ draft.add({
117
+ name: "custom_websearch",
118
+ description: "Search the web for current information using OpenCode's web search backend.",
119
+ input: {
120
+ type: "object",
121
+ properties: {
122
+ query: { type: "string", description: "Web search query" },
123
+ numResults: { type: "integer", minimum: 1, maximum: 20 },
124
+ livecrawl: { type: "string", enum: ["fallback", "preferred"] },
125
+ type: { type: "string", enum: ["auto", "fast", "deep"] },
126
+ contextMaxCharacters: { type: "integer", exclusiveMinimum: 0 },
127
+ },
128
+ required: ["query"],
129
+ additionalProperties: false,
130
+ },
131
+ execute: async (input, context) => {
132
+ const output = await fetchOpenCodeWebSearchText(input, context?.abort);
133
+ return {
134
+ title: `Exa Web Search: ${input.query}`,
135
+ output,
136
+ metadata: { provider: "exa" },
137
+ };
138
+ },
139
+ });
140
+ }));
141
+ // ── Shell timeout wrapper ────────────────────────────────────────────────
142
+ await track(ctx.tool.hook("execute.before", (event) => {
143
+ if (event.tool !== "bash")
144
+ return;
145
+ // No `shell.env` in 2.0 → always use the wrapper-file command form.
146
+ prepareCursorShellArgs(event.callID, event.input, {
147
+ preferWrapperCommand: true,
148
+ });
149
+ }));
150
+ await track(ctx.tool.hook("execute.after", (event) => {
151
+ if (event.tool !== "bash")
152
+ return;
153
+ try {
154
+ if (event.status !== "completed")
155
+ return;
156
+ const result = event.result;
157
+ result.title = cursorShellOriginalCommand(event.callID) ?? result.title;
158
+ result.output = captureCursorShellResult(event.callID, result.output, result.metadata);
159
+ if (result.metadata && typeof result.metadata === "object") {
160
+ const metadata = result.metadata;
161
+ if (typeof metadata.output === "string") {
162
+ metadata.output = sanitizeRegisteredCursorShellOutput(event.callID, metadata.output);
163
+ }
164
+ }
165
+ }
166
+ finally {
167
+ releaseCursorShellEnv(event.callID);
168
+ }
169
+ }));
170
+ // ── Compaction marker + session directory ───────────────────────────────
171
+ // 2.0 removed `chat.params`, and `session.hook("context")` exposes no
172
+ // provider-options channel — only `system`/`messages`/`tools` are mutable.
173
+ // But it does name the owning agent and the session id, and the provider
174
+ // already derives the same session id from request headers, so record
175
+ // both facts here and let doStream read them back via
176
+ // `isCompactionSession` / `getSessionDirectory`.
177
+ await track(ctx.session.hook("context", async (event) => {
178
+ markCompactionSession(event.sessionID, event.agent === "compaction");
179
+ try {
180
+ const info = await ctx.session.get({ sessionID: event.sessionID });
181
+ markSessionDirectory(event.sessionID, info.location?.directory);
182
+ }
183
+ catch {
184
+ // Best effort — falls back to the static workspaceRoot above.
185
+ }
186
+ }));
187
+ // ── Model discovery ──────────────────────────────────────────────────────
188
+ // Idempotent and cheap once satisfied, so it is safe to call from anywhere
189
+ // that might mark the moment credentials became available.
190
+ let modelsLoaded = false;
191
+ let ensureInflight;
192
+ const ensureModels = () => {
193
+ if (modelsLoaded)
194
+ return Promise.resolve();
195
+ ensureInflight ??= (async () => {
196
+ try {
197
+ const token = await accessToken();
198
+ const discovered = await loadModels(cacheDir, token);
199
+ if (!discovered.length)
200
+ return;
201
+ models = discovered;
202
+ modelsLoaded = true;
203
+ // Replays the catalog transform above, this time with models.
204
+ await ctx.catalog.reload().catch(() => { });
205
+ if (token) {
206
+ // Resolve the region-specific Run origin so the first turn doesn't
207
+ // pay for GetServerConfig. Best effort: startSession surfaces real
208
+ // failures.
209
+ await resolveAgentUrl(token, {
210
+ apiBaseURL: cursorApiBaseURL(),
211
+ telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
212
+ }).catch(() => { });
213
+ }
214
+ }
215
+ finally {
216
+ ensureInflight = undefined;
217
+ }
218
+ })();
219
+ return ensureInflight;
220
+ };
221
+ // On a fresh install the user connects *after* startup, and the host has no
222
+ // "connection established" hook we can key on. Poll briefly so models show
223
+ // up within seconds of /connect instead of requiring a restart. The window
224
+ // is bounded; after it, any session/message event still triggers a retry.
225
+ const RETRY_INTERVAL_MS = 3_000;
226
+ const RETRY_WINDOW_MS = 300_000;
227
+ const startedAt = Date.now();
228
+ const retry = setInterval(() => {
229
+ if (modelsLoaded || Date.now() - startedAt > RETRY_WINDOW_MS) {
230
+ clearInterval(retry);
231
+ return;
232
+ }
233
+ void ensureModels();
234
+ }, RETRY_INTERVAL_MS);
235
+ retry.unref?.();
236
+ void ensureModels();
237
+ // ── Session activity ─────────────────────────────────────────────────────
238
+ // Doubles as a retry trigger: any host activity after the polling window has
239
+ // closed still gets one more chance to pick up newly-added credentials.
240
+ const unsubscribe = subscribeSessionActivity(ctx, ensureModels);
241
+ return async () => {
242
+ clearInterval(retry);
243
+ unsubscribe?.();
244
+ for (const registration of registrations.reverse()) {
245
+ await registration.dispose().catch(() => { });
246
+ }
247
+ };
248
+ },
249
+ };
250
+ /** Feed OpenCode session/message events into the shared activity tracker. */
251
+ function subscribeSessionActivity(ctx, onEvent) {
252
+ try {
253
+ const stream = ctx.event.subscribe();
254
+ if (!stream || typeof stream[Symbol.asyncIterator] !== "function")
255
+ return undefined;
256
+ let stopped = false;
257
+ void (async () => {
258
+ for await (const event of stream) {
259
+ if (stopped)
260
+ break;
261
+ applySessionActivity(event);
262
+ onEvent?.();
263
+ }
264
+ })().catch(() => { });
265
+ return () => {
266
+ stopped = true;
267
+ };
268
+ }
269
+ catch {
270
+ return undefined;
271
+ }
272
+ }
273
+ function applySessionActivity(event) {
274
+ switch (event?.type) {
275
+ case "session.created":
276
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
277
+ sessionActivity.recordActivity(event.properties.info.id);
278
+ break;
279
+ case "session.updated":
280
+ sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
281
+ break;
282
+ case "session.deleted":
283
+ sessionActivity.removeSession(event.properties.info.id);
284
+ break;
285
+ case "message.updated":
286
+ sessionActivity.recordActivity(event.properties.info.sessionID);
287
+ break;
288
+ case "message.part.updated":
289
+ sessionActivity.recordActivity(event.properties.part.sessionID);
290
+ break;
291
+ }
292
+ }
293
+ export default plugin;