opencode-cursor-provider 0.2.0 → 0.3.1

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
@@ -47,8 +47,9 @@ The plugin gets the model list and the model variants from Cursor. The fallback
47
47
  ## How the plugin works
48
48
 
49
49
  - Cursor runs its own tools and changes the workspace.
50
- - OpenCode tools stay off for Cursor models.
51
50
  - OpenCode shows Cursor tools as provider-executed tool calls.
51
+ - The plugin exposes OpenCode tools to Cursor with an `opencode__` prefix for each run.
52
+ - OpenCode executes bridged tools through its normal permissions, hooks, and MCP connections.
52
53
  - The plugin keeps one Cursor agent for each OpenCode session.
53
54
  - The plugin starts a new Cursor agent after a model, directory, mode, conversation, or Cursor agent option change.
54
55
  - OpenCode cancellation stops the active Cursor run.
@@ -69,7 +70,7 @@ providerOptions: {
69
70
  }
70
71
  ```
71
72
 
72
- The `tools` and `disallowedTools` options apply only to Cursor tools. They do not add OpenCode tools.
73
+ The `tools` and `disallowedTools` options apply only to Cursor tools. OpenCode controls its bridged tools separately.
73
74
 
74
75
  Because Cursor settings can load MCP servers, select only settings layers that you trust.
75
76
 
@@ -78,10 +79,12 @@ Because Cursor settings can load MCP servers, select only settings layers that y
78
79
  - The plugin supports text and local image input. It supports text output.
79
80
  - The plugin rejects image URLs and non-image file input. It does not discard the input.
80
81
  - The plugin rejects structured output requests.
81
- - The plugin rejects OpenCode tools and explicit tool choice.
82
+ - The plugin rejects explicit tool choice.
83
+ - Tool bridging requires an OpenCode session. One-shot model calls cannot use OpenCode tools.
82
84
  - OpenCode sampling settings do not change Cursor model parameters. The plugin reports those settings as unsupported.
83
85
  - Cursor controls tool access and tool approval for its tools.
84
- - The plugin does not expose Cursor MCP servers, custom tools, extra workspace roots, or a replacement system prompt.
86
+ - OpenCode controls permission checks for bridged OpenCode tools.
87
+ - The plugin does not accept user-defined Cursor custom tools, extra workspace roots, or a replacement system prompt.
85
88
 
86
89
  ## Credentials
87
90
 
@@ -1,5 +1,6 @@
1
1
  import type { CursorLink } from "../auth/link.ts";
2
2
  import type { CursorModelDescriptor } from "../catalog/catalog.ts";
3
+ import type { OpencodeSessionID } from "../ids.ts";
3
4
  import { type BindingStore, type TurnScope } from "./binding.ts";
4
5
  import { type TurnRequest } from "./turn.ts";
5
6
  import type { TurnEvent } from "./translate.ts";
@@ -7,6 +8,8 @@ export type { TurnRequest } from "./turn.ts";
7
8
  export interface SessionAgentBridge {
8
9
  annotate(system: string[], scope: TurnScope): string[];
9
10
  turn(request: TurnRequest): AsyncIterable<TurnEvent>;
11
+ cancel(sessionID: OpencodeSessionID, reason: string): Promise<void>;
12
+ dispose(): Promise<void>;
10
13
  }
11
14
  export declare function createSessionAgentBridge(input: {
12
15
  link: CursorLink;
@@ -17,5 +17,11 @@ export function createSessionAgentBridge(input) {
17
17
  turn(request) {
18
18
  return runTurn(request);
19
19
  },
20
+ cancel(sessionID, reason) {
21
+ return runTurn.cancel(sessionID, reason);
22
+ },
23
+ dispose() {
24
+ return runTurn.dispose();
25
+ },
20
26
  };
21
27
  }
@@ -61,4 +61,10 @@ export declare function resumeTurn(conversation: Conversation, checkpoint: Conve
61
61
  role: "user";
62
62
  }> | undefined;
63
63
  export declare function extendsCheckpoint(conversation: Conversation, checkpoint: ConversationCheckpoint): boolean;
64
+ export declare function toolResultsAfter(conversation: Conversation, checkpoint: ConversationCheckpoint, calls: readonly {
65
+ readonly id: string;
66
+ readonly name: string;
67
+ }[]): readonly Extract<ToolPart, {
68
+ type: "tool-result";
69
+ }>[] | undefined;
64
70
  export declare function canonicalJson(value: unknown): string;
@@ -28,6 +28,36 @@ export function resumeTurn(conversation, checkpoint) {
28
28
  export function extendsCheckpoint(conversation, checkpoint) {
29
29
  return resumeTurn(conversation, checkpoint) !== undefined;
30
30
  }
31
+ export function toolResultsAfter(conversation, checkpoint, calls) {
32
+ if (digest(conversation.system) !== checkpoint.system)
33
+ return undefined;
34
+ if (conversation.turns.length !== checkpoint.turns.length + 1)
35
+ return undefined;
36
+ for (let index = 0; index < checkpoint.turns.length; index += 1) {
37
+ const turn = conversation.turns[index];
38
+ if (turn === undefined || digest(turn) !== checkpoint.turns[index])
39
+ return undefined;
40
+ }
41
+ const suffix = conversation.turns.at(-1);
42
+ if (suffix?.role !== "tool")
43
+ return undefined;
44
+ if (suffix.parts.length !== calls.length)
45
+ return undefined;
46
+ const expected = new Map(calls.map((call) => [call.id, call.name]));
47
+ if (expected.size !== calls.length)
48
+ return undefined;
49
+ const found = new Set();
50
+ const results = [];
51
+ for (const part of suffix.parts) {
52
+ if (part.type !== "tool-result")
53
+ return undefined;
54
+ if (expected.get(part.id) !== part.name || found.has(part.id))
55
+ return undefined;
56
+ found.add(part.id);
57
+ results.push(part);
58
+ }
59
+ return results;
60
+ }
31
61
  export function canonicalJson(value) {
32
62
  return JSON.stringify(ordered(value)) ?? "null";
33
63
  }
@@ -15,7 +15,7 @@ export function createResponseJournal() {
15
15
  }
16
16
  return event;
17
17
  }
18
- if (event.type === "tool-call") {
18
+ if (event.type === "tool-call" || event.type === "tool-request") {
19
19
  if (toolCalls.has(event.id))
20
20
  return undefined;
21
21
  toolCalls.add(event.id);
@@ -0,0 +1,26 @@
1
+ import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
2
+ import type { UserPart } from "./conversation.ts";
3
+ export interface OpenCodeToolRequest {
4
+ readonly id: string;
5
+ readonly name: string;
6
+ readonly input: Record<string, SDKJsonValue>;
7
+ }
8
+ export interface OpenCodeToolDefinition {
9
+ readonly name: string;
10
+ readonly description?: string;
11
+ readonly inputSchema: Record<string, SDKJsonValue>;
12
+ }
13
+ export interface OpenCodeToolResult {
14
+ readonly id: string;
15
+ readonly output: readonly UserPart[];
16
+ readonly isError: boolean;
17
+ }
18
+ export interface OpenCodeToolBridge {
19
+ readonly customTools: Record<string, SDKCustomTool>;
20
+ waitForCalls(): Promise<void>;
21
+ takeCalls(): readonly OpenCodeToolRequest[];
22
+ resolve(results: readonly OpenCodeToolResult[]): number;
23
+ isCursorCall(id: string): boolean;
24
+ cancel(reason: string): void;
25
+ }
26
+ export declare function createOpenCodeToolBridge(tools: readonly OpenCodeToolDefinition[], bridgeID?: string): OpenCodeToolBridge;
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const TOOL_PREFIX = "opencode__";
3
+ const CODE_MODE_DISCOVERY = [
4
+ "OpenCode MCP tools are available only inside this Code Mode tool.",
5
+ "Do not search for them with Cursor GetDynamicTools.",
6
+ 'To find an MCP tool, call this tool with code such as `return await tools.$codemode.search({ query: "posthog" })`.',
7
+ ].join("\n");
8
+ export function createOpenCodeToolBridge(tools, bridgeID = randomUUID()) {
9
+ const queued = [];
10
+ const pending = new Map();
11
+ const cursorCallIDs = new Set();
12
+ let sequence = 0;
13
+ let waiter;
14
+ let wake;
15
+ const entries = tools
16
+ .map((tool) => [
17
+ `${TOOL_PREFIX}${tool.name}`,
18
+ {
19
+ ...(tool.description === undefined && tool.name !== "execute"
20
+ ? {}
21
+ : {
22
+ description: tool.name === "execute"
23
+ ? [CODE_MODE_DISCOVERY, tool.description].filter((item) => item !== undefined).join("\n\n")
24
+ : tool.description,
25
+ }),
26
+ inputSchema: tool.inputSchema,
27
+ execute(args, context) {
28
+ sequence += 1;
29
+ if (context.toolCallId !== undefined)
30
+ cursorCallIDs.add(context.toolCallId);
31
+ const id = `${bridgeID}-${sequence}`;
32
+ const request = { id, name: tool.name, input: args };
33
+ queued.push(request);
34
+ const waiting = new Promise((resolve, reject) => {
35
+ pending.set(id, { request, resolve, reject });
36
+ });
37
+ wake?.();
38
+ wake = undefined;
39
+ waiter = undefined;
40
+ return waiting;
41
+ },
42
+ },
43
+ ]);
44
+ return {
45
+ customTools: Object.fromEntries(entries),
46
+ waitForCalls() {
47
+ if (queued.length > 0)
48
+ return Promise.resolve();
49
+ if (waiter !== undefined)
50
+ return waiter;
51
+ waiter = new Promise((resolve) => {
52
+ wake = resolve;
53
+ });
54
+ return waiter;
55
+ },
56
+ takeCalls() {
57
+ return queued.splice(0);
58
+ },
59
+ resolve(results) {
60
+ let resolved = 0;
61
+ for (const result of results) {
62
+ const call = pending.get(result.id);
63
+ if (call === undefined)
64
+ continue;
65
+ pending.delete(result.id);
66
+ resolved += 1;
67
+ call.resolve({
68
+ content: result.output.map((part) => {
69
+ if (part.type === "text")
70
+ return { type: "text", text: part.text };
71
+ return { type: "image", data: part.image.data, mimeType: part.image.mimeType };
72
+ }),
73
+ ...(result.isError ? { isError: true } : {}),
74
+ });
75
+ }
76
+ return resolved;
77
+ },
78
+ isCursorCall(id) {
79
+ return cursorCallIDs.has(id);
80
+ },
81
+ cancel(reason) {
82
+ const error = new Error(reason);
83
+ for (const call of pending.values())
84
+ call.reject(error);
85
+ pending.clear();
86
+ queued.splice(0);
87
+ wake?.();
88
+ wake = undefined;
89
+ waiter = undefined;
90
+ },
91
+ };
92
+ }
@@ -19,6 +19,11 @@ export type TurnEvent = {
19
19
  readonly id: string;
20
20
  readonly name: string;
21
21
  readonly input: JsonValue;
22
+ } | {
23
+ readonly type: "tool-request";
24
+ readonly id: string;
25
+ readonly name: string;
26
+ readonly input: JsonValue;
22
27
  } | {
23
28
  readonly type: "tool-result";
24
29
  readonly id: string;
@@ -35,7 +40,7 @@ export type TurnEvent = {
35
40
  readonly total: number;
36
41
  } | {
37
42
  readonly type: "done";
38
- readonly reason: "stop" | "length" | "aborted";
43
+ readonly reason: "stop" | "length" | "aborted" | "tool-calls";
39
44
  readonly metadata?: {
40
45
  readonly runId: string;
41
46
  readonly requestId?: string;
@@ -1,17 +1,19 @@
1
1
  import { type AgentModeOption, type ModelParameterValue } from "@cursor/sdk";
2
2
  import type { CursorLink } from "../auth/link.ts";
3
3
  import { type CursorModelDescriptor } from "../catalog/catalog.ts";
4
- import { type CatalogModelID } from "../ids.ts";
4
+ import { type CatalogModelID, type OpencodeSessionID } from "../ids.ts";
5
5
  import type { CursorAgentOptions } from "../model/provider-options.ts";
6
6
  import { openCursorAgent } from "./agent.ts";
7
7
  import { type BindingStore, type TurnScope } from "./binding.ts";
8
8
  import { type Conversation } from "./conversation.ts";
9
9
  import type { KeyedLock } from "./lock.ts";
10
+ import { type OpenCodeToolDefinition } from "./tool-bridge.ts";
10
11
  import { type TurnEvent } from "./translate.ts";
11
12
  export interface TurnRequest {
12
13
  readonly modelID: CatalogModelID;
13
14
  readonly scope: TurnScope | undefined;
14
15
  readonly conversation: Conversation;
16
+ readonly tools?: readonly OpenCodeToolDefinition[];
15
17
  readonly params?: readonly ModelParameterValue[];
16
18
  readonly mode?: AgentModeOption;
17
19
  readonly agentOptions?: CursorAgentOptions;
@@ -26,4 +28,8 @@ export interface TurnRunnerContext {
26
28
  readonly lock: KeyedLock;
27
29
  readonly openAgent?: typeof openCursorAgent;
28
30
  }
29
- export declare function createTurnRunner(ctx: TurnRunnerContext): (request: TurnRequest) => AsyncIterable<TurnEvent>;
31
+ export declare function createTurnRunner(ctx: TurnRunnerContext): ((request: TurnRequest) => AsyncGenerator<TurnEvent, any, any>) & {
32
+ cancel(sessionID: OpencodeSessionID, reason: string): Promise<void>;
33
+ dispose(): Promise<void>;
34
+ };
35
+ export type TurnRunner = ReturnType<typeof createTurnRunner>;
@@ -3,16 +3,81 @@ import { resolveWireId } from "../catalog/catalog.js";
3
3
  import { nowMs } from "../ids.js";
4
4
  import { isAgentLost, openCursorAgent } from "./agent.js";
5
5
  import { route } from "./binding.js";
6
- import { checkpointOf, cursorMessage, resumeTurn, } from "./conversation.js";
6
+ import { canonicalJson, checkpointOf, cursorMessage, resumeTurn, toolResultsAfter, } from "./conversation.js";
7
7
  import { createResponseJournal } from "./response-journal.js";
8
+ import { createOpenCodeToolBridge, } from "./tool-bridge.js";
8
9
  import { createMessageTranslator } from "./translate.js";
9
10
  export function createTurnRunner(ctx) {
10
- return (request) => runTurn(ctx, request);
11
+ const liveRuns = new Map();
12
+ return Object.assign((request) => runTurn(ctx, request, liveRuns), {
13
+ async cancel(sessionID, reason) {
14
+ const release = await ctx.lock.acquire(sessionID);
15
+ try {
16
+ const live = liveRuns.get(sessionID);
17
+ if (live === undefined)
18
+ return;
19
+ liveRuns.delete(sessionID);
20
+ await closeLiveRun(live, reason);
21
+ }
22
+ finally {
23
+ release();
24
+ }
25
+ },
26
+ async dispose() {
27
+ await Promise.all([...liveRuns.values()].map((live) => closeLiveRun(live, "Cursor provider stopped")));
28
+ liveRuns.clear();
29
+ },
30
+ });
11
31
  }
12
- async function* runTurn(ctx, request) {
32
+ async function* runTurn(ctx, request, liveRuns) {
13
33
  const lockKey = request.scope?.sessionID ?? "one-shot";
14
34
  const release = await ctx.lock.acquire(lockKey);
15
35
  try {
36
+ const scope = request.scope;
37
+ if (scope === undefined && (request.tools?.length ?? 0) > 0) {
38
+ yield { type: "failed", error: { kind: "unsupported-request", reason: "tools-requested" } };
39
+ return;
40
+ }
41
+ const suspended = scope === undefined ? undefined : liveRuns.get(scope.sessionID);
42
+ if (scope !== undefined && suspended !== undefined) {
43
+ if (suspended.identity !== liveIdentity(request)) {
44
+ await closeLiveRun(suspended, "The OpenCode request changed while a tool call was pending");
45
+ liveRuns.delete(scope.sessionID);
46
+ ctx.bindings.drop(scope.sessionID);
47
+ }
48
+ else {
49
+ const continuation = suspended.continuation;
50
+ const parts = continuation === undefined
51
+ ? undefined
52
+ : toolResultsAfter(request.conversation, continuation.checkpoint, continuation.calls);
53
+ const resolved = parts === undefined
54
+ ? 0
55
+ : suspended.toolBridge?.resolve(parts.map((part) => ({ id: part.id, output: part.output, isError: part.isError }))) ?? 0;
56
+ if (resolved !== continuation?.calls.length) {
57
+ await closeLiveRun(suspended, "OpenCode did not return the pending tool result");
58
+ liveRuns.delete(scope.sessionID);
59
+ ctx.bindings.drop(scope.sessionID);
60
+ }
61
+ else {
62
+ let outcome;
63
+ try {
64
+ outcome = yield* drainLiveRun(ctx, request, suspended);
65
+ }
66
+ catch (error) {
67
+ liveRuns.delete(scope.sessionID);
68
+ ctx.bindings.drop(scope.sessionID);
69
+ await closeLiveRun(suspended, "The Cursor run failed");
70
+ yield failedFromCaught(error, request.signal, ctx.link.reject);
71
+ return;
72
+ }
73
+ if (outcome === "suspended")
74
+ return;
75
+ liveRuns.delete(scope.sessionID);
76
+ await suspended.session.dispose();
77
+ return;
78
+ }
79
+ }
80
+ }
16
81
  const apiKey = await ctx.link.resolve();
17
82
  if (apiKey === undefined) {
18
83
  yield { type: "failed", error: { kind: "not-linked" } };
@@ -49,7 +114,13 @@ async function* runTurn(ctx, request) {
49
114
  yield { type: "failed", error: { kind: "cancelled" } };
50
115
  return;
51
116
  }
52
- const run = await session.send(plan.prompt, request.mode === undefined ? undefined : { mode: request.mode });
117
+ const toolBridge = request.scope === undefined ? undefined : createOpenCodeToolBridge(request.tools ?? []);
118
+ const customTools = toolBridge === undefined ? {} : toolBridge.customTools;
119
+ const sendOptions = {
120
+ ...(request.mode === undefined ? {} : { mode: request.mode }),
121
+ ...(Object.keys(customTools).length === 0 ? {} : { local: { customTools } }),
122
+ };
123
+ const run = await session.send(plan.prompt, Object.keys(sendOptions).length === 0 ? undefined : sendOptions);
53
124
  emitted = true;
54
125
  yield {
55
126
  type: "response-metadata",
@@ -57,82 +128,24 @@ async function* runTurn(ctx, request) {
57
128
  ...(run.createdAt === undefined ? {} : { timestamp: run.createdAt }),
58
129
  modelId: run.model?.id ?? plan.wireID,
59
130
  };
60
- let cancelPromise;
61
- const cancel = () => {
62
- if (cancelPromise !== undefined || !run.supports("cancel"))
63
- return;
64
- cancelPromise = run.cancel().catch(() => { });
131
+ const live = {
132
+ identity: liveIdentity(request),
133
+ session,
134
+ run,
135
+ messages: run.stream()[Symbol.asyncIterator](),
136
+ toolBridge,
137
+ translate: createMessageTranslator(),
138
+ plan,
139
+ nextMessage: undefined,
140
+ continuation: undefined,
141
+ sawUsage: false,
65
142
  };
66
- request.signal?.addEventListener("abort", cancel);
67
- if (request.signal?.aborted)
68
- cancel();
69
- let sawUsage = false;
70
- const response = createResponseJournal();
71
- const translate = createMessageTranslator();
72
- try {
73
- for await (const message of run.stream()) {
74
- if (request.signal?.aborted) {
75
- yield { type: "failed", error: { kind: "cancelled" } };
76
- return;
77
- }
78
- if (request.includeRawChunks === true)
79
- yield { type: "raw", value: message };
80
- for (const candidate of translate(message)) {
81
- const event = response.accept(candidate);
82
- if (event === undefined)
83
- continue;
84
- if (event.type === "usage")
85
- sawUsage = true;
86
- yield event;
87
- }
88
- }
89
- if (request.signal?.aborted) {
90
- yield { type: "failed", error: { kind: "cancelled" } };
91
- return;
92
- }
93
- const result = await run.wait();
94
- if (result.status === "cancelled") {
95
- yield { type: "failed", error: { kind: "cancelled" } };
96
- return;
97
- }
98
- if (!sawUsage && result.usage !== undefined)
99
- yield usageEvent(result.usage);
100
- if (result.status === "error") {
101
- yield {
102
- type: "failed",
103
- error: { kind: "agent-run-failed", detail: result.error?.message ?? result.id },
104
- };
105
- return;
106
- }
107
- if (request.scope && plan.kind !== "ONE_SHOT") {
108
- ctx.bindings.put({
109
- sessionID: request.scope.sessionID,
110
- agentID: session.id,
111
- modelID: request.modelID,
112
- cwd: request.scope.cwd,
113
- checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
114
- params: request.params,
115
- mode: request.mode,
116
- agentOptions: request.agentOptions,
117
- lastUsedAt: nowMs(ctx.clock),
118
- });
119
- }
120
- yield {
121
- type: "done",
122
- reason: "stop",
123
- metadata: {
124
- runId: result.id,
125
- ...(result.requestId === undefined ? {} : { requestId: result.requestId }),
126
- ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
127
- ...(result.model === undefined ? {} : { modelId: result.model.id }),
128
- },
129
- };
130
- return;
131
- }
132
- finally {
133
- request.signal?.removeEventListener("abort", cancel);
134
- await cancelPromise;
143
+ const outcome = yield* drainLiveRun(ctx, request, live);
144
+ if (outcome === "suspended" && request.scope !== undefined) {
145
+ liveRuns.set(request.scope.sessionID, live);
146
+ session = undefined;
135
147
  }
148
+ return;
136
149
  }
137
150
  catch (error) {
138
151
  if (retryLost && !emitted && plan.kind === "RESUME" && request.scope && isAgentLost(error)) {
@@ -158,6 +171,125 @@ async function* runTurn(ctx, request) {
158
171
  release();
159
172
  }
160
173
  }
174
+ function liveIdentity(request) {
175
+ return canonicalJson({
176
+ modelID: request.modelID,
177
+ cwd: request.scope?.cwd,
178
+ params: request.params,
179
+ mode: request.mode,
180
+ agentOptions: request.agentOptions,
181
+ tools: request.tools,
182
+ });
183
+ }
184
+ async function* drainLiveRun(ctx, request, live) {
185
+ let cancelPromise;
186
+ const cancel = () => {
187
+ live.toolBridge?.cancel("The OpenCode request was cancelled");
188
+ if (cancelPromise !== undefined || !live.run.supports("cancel"))
189
+ return;
190
+ cancelPromise = live.run.cancel().catch(() => { });
191
+ };
192
+ request.signal?.addEventListener("abort", cancel);
193
+ if (request.signal?.aborted)
194
+ cancel();
195
+ const response = createResponseJournal();
196
+ try {
197
+ while (true) {
198
+ if (request.signal?.aborted) {
199
+ yield { type: "failed", error: { kind: "cancelled" } };
200
+ return "finished";
201
+ }
202
+ const nextMessage = live.nextMessage ?? live.messages.next();
203
+ live.nextMessage = nextMessage;
204
+ const next = live.toolBridge === undefined
205
+ ? { type: "message", value: await nextMessage }
206
+ : await Promise.race([
207
+ nextMessage.then((value) => ({ type: "message", value })),
208
+ live.toolBridge.waitForCalls().then(() => ({ type: "tools" })),
209
+ ]);
210
+ if (next.type === "tools") {
211
+ const calls = live.toolBridge?.takeCalls() ?? [];
212
+ if (calls.length === 0)
213
+ continue;
214
+ for (const call of calls) {
215
+ const event = { type: "tool-request", ...call };
216
+ response.accept(event);
217
+ yield event;
218
+ }
219
+ live.continuation = {
220
+ checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
221
+ calls: calls.map((call) => ({ id: call.id, name: call.name })),
222
+ };
223
+ yield { type: "done", reason: "tool-calls" };
224
+ return "suspended";
225
+ }
226
+ live.nextMessage = undefined;
227
+ if (next.value.done)
228
+ break;
229
+ const message = next.value.value;
230
+ if (request.includeRawChunks === true)
231
+ yield { type: "raw", value: message };
232
+ for (const candidate of live.translate(message)) {
233
+ if ((candidate.type === "tool-call" || candidate.type === "tool-result") &&
234
+ live.toolBridge?.isCursorCall(candidate.id)) {
235
+ continue;
236
+ }
237
+ const event = response.accept(candidate);
238
+ if (event === undefined)
239
+ continue;
240
+ if (event.type === "usage")
241
+ live.sawUsage = true;
242
+ yield event;
243
+ }
244
+ }
245
+ const result = await live.run.wait();
246
+ if (result.status === "cancelled") {
247
+ yield { type: "failed", error: { kind: "cancelled" } };
248
+ return "finished";
249
+ }
250
+ if (!live.sawUsage && result.usage !== undefined)
251
+ yield usageEvent(result.usage);
252
+ if (result.status === "error") {
253
+ yield { type: "failed", error: { kind: "agent-run-failed", detail: result.error?.message ?? result.id } };
254
+ return "finished";
255
+ }
256
+ if (request.scope && live.plan.kind !== "ONE_SHOT") {
257
+ ctx.bindings.put({
258
+ sessionID: request.scope.sessionID,
259
+ agentID: live.session.id,
260
+ modelID: request.modelID,
261
+ cwd: request.scope.cwd,
262
+ checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
263
+ params: request.params,
264
+ mode: request.mode,
265
+ agentOptions: request.agentOptions,
266
+ lastUsedAt: nowMs(ctx.clock),
267
+ });
268
+ }
269
+ yield {
270
+ type: "done",
271
+ reason: "stop",
272
+ metadata: {
273
+ runId: result.id,
274
+ ...(result.requestId === undefined ? {} : { requestId: result.requestId }),
275
+ ...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
276
+ ...(result.model === undefined ? {} : { modelId: result.model.id }),
277
+ },
278
+ };
279
+ return "finished";
280
+ }
281
+ finally {
282
+ request.signal?.removeEventListener("abort", cancel);
283
+ await cancelPromise;
284
+ }
285
+ }
286
+ async function closeLiveRun(live, reason) {
287
+ live.toolBridge?.cancel(reason);
288
+ if (live.run.supports("cancel"))
289
+ await live.run.cancel().catch(() => { });
290
+ await live.nextMessage?.catch(() => { });
291
+ await live.session.dispose();
292
+ }
161
293
  function withResponse(conversation, parts) {
162
294
  if (parts.length === 0)
163
295
  return conversation;
@@ -47,7 +47,7 @@ function writeModel(draft, catalogID, model) {
47
47
  entry.modelID = Model.ID.make(model.wireID);
48
48
  entry.providerID = defaults.providerID;
49
49
  entry.name = model.name;
50
- entry.capabilities = { tools: false, input: ["text", "image"], output: ["text"] };
50
+ entry.capabilities = { tools: true, input: ["text", "image"], output: ["text"] };
51
51
  entry.variants = toCatalogVariants(model);
52
52
  entry.time = defaults.time;
53
53
  entry.cost = defaults.cost;
@@ -95,9 +95,6 @@ function appendReasoning(content, delta) {
95
95
  content.push({ type: "reasoning", text: delta });
96
96
  }
97
97
  function parseCall(options, modelID, params) {
98
- if (options.tools !== undefined && options.tools.length > 0) {
99
- refuse({ kind: "unsupported-request", reason: "tools-requested" });
100
- }
101
98
  if (options.toolChoice !== undefined && options.toolChoice.type !== "auto") {
102
99
  refuse({ kind: "unsupported-request", reason: "tool-choice" });
103
100
  }
@@ -132,16 +129,51 @@ function parseCall(options, modelID, params) {
132
129
  const extracted = extractScope(system);
133
130
  const conversation = { system: extracted.system, turns };
134
131
  const cursor = parseCursorOptions(options.providerOptions?.cursor);
132
+ const tools = parseTools(options.tools);
135
133
  return {
136
134
  modelID,
137
135
  scope: extracted.scope,
138
136
  conversation,
137
+ ...(tools.length === 0 ? {} : { tools }),
139
138
  ...cursor,
140
139
  ...(options.includeRawChunks === true ? { includeRawChunks: true } : {}),
141
140
  ...(params === undefined ? {} : { params }),
142
141
  ...(options.abortSignal === undefined ? {} : { signal: options.abortSignal }),
143
142
  };
144
143
  }
144
+ function parseTools(tools) {
145
+ if (tools === undefined)
146
+ return [];
147
+ const parsed = [];
148
+ for (const tool of tools) {
149
+ if (tool.type !== "function")
150
+ refuse({ kind: "unsupported-request", reason: "tools-requested" });
151
+ const inputSchema = sdkJsonValue(tool.inputSchema);
152
+ if (typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema)) {
153
+ refuse({ kind: "unsupported-request", reason: "tools-requested" });
154
+ }
155
+ parsed.push({
156
+ name: tool.name,
157
+ ...(tool.description === undefined ? {} : { description: tool.description }),
158
+ inputSchema,
159
+ });
160
+ }
161
+ return parsed;
162
+ }
163
+ function sdkJsonValue(value) {
164
+ if (value === null || typeof value === "string" || typeof value === "boolean")
165
+ return value;
166
+ if (typeof value === "number")
167
+ return Number.isFinite(value) ? value : String(value);
168
+ if (Array.isArray(value))
169
+ return value.map(sdkJsonValue);
170
+ if (typeof value === "object") {
171
+ return Object.fromEntries(Object.entries(value)
172
+ .filter((entry) => entry[1] !== undefined)
173
+ .map(([key, item]) => [key, sdkJsonValue(item)]));
174
+ }
175
+ return String(value);
176
+ }
145
177
  function assistantParts(content) {
146
178
  const parts = [];
147
179
  for (const part of content) {
@@ -352,6 +384,18 @@ function toStreamParts(events, abort, warnings) {
352
384
  });
353
385
  tools.set(event.id, "called");
354
386
  break;
387
+ case "tool-request":
388
+ if (tools.has(event.id))
389
+ break;
390
+ closeOpenParts();
391
+ controller.enqueue({
392
+ type: "tool-call",
393
+ toolCallId: event.id,
394
+ toolName: event.name,
395
+ input: JSON.stringify(event.input),
396
+ });
397
+ tools.set(event.id, "called");
398
+ break;
355
399
  case "tool-result":
356
400
  if (tools.get(event.id) === "completed")
357
401
  break;
@@ -535,5 +579,7 @@ function reasonFrom(reason) {
535
579
  return { unified: "stop", raw: reason };
536
580
  if (reason === "length")
537
581
  return { unified: "length", raw: reason };
582
+ if (reason === "tool-calls")
583
+ return { unified: "tool-calls", raw: reason };
538
584
  return { unified: "other", raw: reason };
539
585
  }
package/dist/plugin.d.ts CHANGED
@@ -4,7 +4,12 @@ export interface ModelWatcherDependencies {
4
4
  onLinked(listener: () => void): () => void;
5
5
  subscribe(signal: AbortSignal): AsyncIterable<{
6
6
  readonly type: string;
7
+ readonly data?: unknown;
7
8
  }>;
9
+ onEvent?(event: {
10
+ readonly type: string;
11
+ readonly data?: unknown;
12
+ }): Promise<void> | void;
8
13
  refresh(): Promise<void>;
9
14
  close(): Promise<void>;
10
15
  }
package/dist/plugin.js CHANGED
@@ -4,7 +4,7 @@ import { createSessionAgentBridge } from "./bridge/bridge.js";
4
4
  import { sessionFromId } from "./bridge/correlation.js";
5
5
  import { applyModels, applyProvider, modelParamsFromOptions } from "./catalog/catalog.js";
6
6
  import { createModelSource } from "./catalog/source.js";
7
- import { asCatalogModelID, PROVIDER_ID } from "./ids.js";
7
+ import { asCatalogModelID, asSessionID, PROVIDER_ID } from "./ids.js";
8
8
  import { toLanguageModel } from "./model/language-model.js";
9
9
  import { bindRuntime } from "./runtime.js";
10
10
  const REFRESH_EVERY_MS = 5_000;
@@ -42,7 +42,6 @@ export const plugin = Plugin.define({
42
42
  applyModels(draft, models.list());
43
43
  }),
44
44
  await ctx.session.hook("context", async (event) => {
45
- event.tools = {};
46
45
  if (String(event.agent) === "compaction" || String(event.agent) === "title")
47
46
  return;
48
47
  const stamped = bridge.annotate(event.system.map((part) => part.text), sessionFromId(event.sessionID, await sessionCwd(ctx, event.sessionID)));
@@ -62,11 +61,17 @@ export const plugin = Plugin.define({
62
61
  const stopRefresh = watchModels({
63
62
  onLinked: (listener) => link.onLinked(listener),
64
63
  subscribe: (signal) => ctx.event.subscribe({ signal }),
64
+ onEvent: async (event) => {
65
+ const sessionID = endedSessionID(event);
66
+ if (sessionID !== undefined)
67
+ await bridge.cancel(asSessionID(sessionID), "The OpenCode session stopped");
68
+ },
65
69
  refresh: () => models.refresh(),
66
70
  close: () => models.close(),
67
71
  });
68
72
  return async () => {
69
73
  await stopRefresh();
74
+ await bridge.dispose();
70
75
  for (const registration of registrations.toReversed()) {
71
76
  await registration.dispose();
72
77
  }
@@ -118,6 +123,7 @@ export function watchModels(dependencies) {
118
123
  const eventTask = (async () => {
119
124
  try {
120
125
  for await (const event of dependencies.subscribe(events.signal)) {
126
+ await dependencies.onEvent?.(event);
121
127
  if (event.type === "credential.updated" || event.type === "credential.switched")
122
128
  refresh();
123
129
  }
@@ -147,3 +153,10 @@ export function watchModels(dependencies) {
147
153
  return cleanup;
148
154
  };
149
155
  }
156
+ function endedSessionID(event) {
157
+ if (event.type !== "session.idle" && event.type !== "session.deleted")
158
+ return undefined;
159
+ if (typeof event.data !== "object" || event.data === null || !("sessionID" in event.data))
160
+ return undefined;
161
+ return typeof event.data.sessionID === "string" ? event.data.sessionID : undefined;
162
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-cursor-provider",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "OpenCode 2 plugin that connects Cursor via the official SDK",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",