opencode-cursor-provider 0.1.1 → 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.
- package/README.md +20 -4
- package/dist/bridge/bridge.d.ts +3 -0
- package/dist/bridge/bridge.js +6 -0
- package/dist/bridge/conversation.d.ts +6 -0
- package/dist/bridge/conversation.js +30 -0
- package/dist/bridge/response-journal.js +1 -1
- package/dist/bridge/tool-bridge.d.ts +26 -0
- package/dist/bridge/tool-bridge.js +81 -0
- package/dist/bridge/translate.d.ts +6 -1
- package/dist/bridge/turn.d.ts +8 -2
- package/dist/bridge/turn.js +210 -78
- package/dist/catalog/catalog.js +1 -1
- package/dist/catalog/source.d.ts +11 -4
- package/dist/catalog/source.js +74 -21
- package/dist/model/language-model.js +49 -3
- package/dist/plugin.d.ts +14 -0
- package/dist/plugin.js +49 -14
- package/package.json +9 -1
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.
|
|
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
|
|
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
|
-
-
|
|
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
|
|
|
@@ -107,6 +110,19 @@ bun run build
|
|
|
107
110
|
|
|
108
111
|
The source code is in `src/`. The build writes the package files to `dist/`.
|
|
109
112
|
|
|
113
|
+
## Release
|
|
114
|
+
|
|
115
|
+
Releases use [Release Please](https://github.com/googleapis/release-please) with conventional commits (`feat:`, `fix:`, `docs:`, …). Merging to `main` opens or updates a release PR. Merging that PR tags a GitHub release and publishes to npm.
|
|
116
|
+
|
|
117
|
+
Configure an npm trusted publisher with these values:
|
|
118
|
+
|
|
119
|
+
- Organization or user: `pragmaticivan`
|
|
120
|
+
- Repository: `opencode-cursor-provider`
|
|
121
|
+
- Workflow filename: `release-please.yml`
|
|
122
|
+
- Allowed action: `npm publish`
|
|
123
|
+
|
|
124
|
+
The workflow uses OpenID Connect and does not need an npm token.
|
|
125
|
+
|
|
110
126
|
## License
|
|
111
127
|
|
|
112
128
|
Apache License 2.0. See [`LICENSE`](LICENSE).
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -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;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const TOOL_PREFIX = "opencode__";
|
|
3
|
+
export function createOpenCodeToolBridge(tools, bridgeID = randomUUID()) {
|
|
4
|
+
const queued = [];
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
const cursorCallIDs = new Set();
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
let waiter;
|
|
9
|
+
let wake;
|
|
10
|
+
const entries = tools
|
|
11
|
+
.map((tool) => [
|
|
12
|
+
`${TOOL_PREFIX}${tool.name}`,
|
|
13
|
+
{
|
|
14
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
15
|
+
inputSchema: tool.inputSchema,
|
|
16
|
+
execute(args, context) {
|
|
17
|
+
sequence += 1;
|
|
18
|
+
if (context.toolCallId !== undefined)
|
|
19
|
+
cursorCallIDs.add(context.toolCallId);
|
|
20
|
+
const id = `${bridgeID}-${sequence}`;
|
|
21
|
+
const request = { id, name: tool.name, input: args };
|
|
22
|
+
queued.push(request);
|
|
23
|
+
const waiting = new Promise((resolve, reject) => {
|
|
24
|
+
pending.set(id, { request, resolve, reject });
|
|
25
|
+
});
|
|
26
|
+
wake?.();
|
|
27
|
+
wake = undefined;
|
|
28
|
+
waiter = undefined;
|
|
29
|
+
return waiting;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
return {
|
|
34
|
+
customTools: Object.fromEntries(entries),
|
|
35
|
+
waitForCalls() {
|
|
36
|
+
if (queued.length > 0)
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
if (waiter !== undefined)
|
|
39
|
+
return waiter;
|
|
40
|
+
waiter = new Promise((resolve) => {
|
|
41
|
+
wake = resolve;
|
|
42
|
+
});
|
|
43
|
+
return waiter;
|
|
44
|
+
},
|
|
45
|
+
takeCalls() {
|
|
46
|
+
return queued.splice(0);
|
|
47
|
+
},
|
|
48
|
+
resolve(results) {
|
|
49
|
+
let resolved = 0;
|
|
50
|
+
for (const result of results) {
|
|
51
|
+
const call = pending.get(result.id);
|
|
52
|
+
if (call === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
pending.delete(result.id);
|
|
55
|
+
resolved += 1;
|
|
56
|
+
call.resolve({
|
|
57
|
+
content: result.output.map((part) => {
|
|
58
|
+
if (part.type === "text")
|
|
59
|
+
return { type: "text", text: part.text };
|
|
60
|
+
return { type: "image", data: part.image.data, mimeType: part.image.mimeType };
|
|
61
|
+
}),
|
|
62
|
+
...(result.isError ? { isError: true } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return resolved;
|
|
66
|
+
},
|
|
67
|
+
isCursorCall(id) {
|
|
68
|
+
return cursorCallIDs.has(id);
|
|
69
|
+
},
|
|
70
|
+
cancel(reason) {
|
|
71
|
+
const error = new Error(reason);
|
|
72
|
+
for (const call of pending.values())
|
|
73
|
+
call.reject(error);
|
|
74
|
+
pending.clear();
|
|
75
|
+
queued.splice(0);
|
|
76
|
+
wake?.();
|
|
77
|
+
wake = undefined;
|
|
78
|
+
waiter = undefined;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -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;
|
package/dist/bridge/turn.d.ts
CHANGED
|
@@ -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) =>
|
|
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>;
|
package/dist/bridge/turn.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
if (request.
|
|
68
|
-
|
|
69
|
-
|
|
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;
|
package/dist/catalog/catalog.js
CHANGED
|
@@ -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:
|
|
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;
|
package/dist/catalog/source.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type { CursorLink } from "../auth/link.ts";
|
|
1
|
+
import type { CursorApiKey } from "../ids.ts";
|
|
3
2
|
import { type CursorModelDescriptor } from "./catalog.ts";
|
|
3
|
+
export interface ModelSourceDependencies {
|
|
4
|
+
restoreCredential(): Promise<void>;
|
|
5
|
+
resolveApiKey(): Promise<CursorApiKey | undefined>;
|
|
6
|
+
rejectCredential(): void;
|
|
7
|
+
reloadCatalog(): Promise<void>;
|
|
8
|
+
listModels?(apiKey: CursorApiKey): Promise<unknown>;
|
|
9
|
+
}
|
|
4
10
|
export interface ModelSource {
|
|
5
11
|
list(): readonly CursorModelDescriptor[];
|
|
6
|
-
refresh(
|
|
12
|
+
refresh(): Promise<void>;
|
|
13
|
+
close(): Promise<void>;
|
|
7
14
|
}
|
|
8
|
-
export declare function createModelSource(
|
|
15
|
+
export declare function createModelSource(dependencies: ModelSourceDependencies): ModelSource;
|
package/dist/catalog/source.js
CHANGED
|
@@ -1,30 +1,83 @@
|
|
|
1
1
|
import { AuthenticationError, Cursor } from "@cursor/sdk";
|
|
2
2
|
import { parseListedModels, SEED_MODELS } from "./catalog.js";
|
|
3
|
-
export function createModelSource(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
3
|
+
export function createModelSource(dependencies) {
|
|
4
|
+
const listModels = dependencies.listModels ?? ((apiKey) => Cursor.models.list({ apiKey }));
|
|
5
|
+
let snapshot = { kind: "seed", models: SEED_MODELS };
|
|
6
|
+
let catalogDirty = false;
|
|
7
|
+
let activeDrain;
|
|
8
|
+
let trailingRefresh = false;
|
|
9
|
+
let closed = false;
|
|
10
|
+
let closing;
|
|
11
|
+
async function reloadDirtyCatalog() {
|
|
12
|
+
if (!catalogDirty)
|
|
13
|
+
return;
|
|
14
|
+
try {
|
|
15
|
+
await dependencies.reloadCatalog();
|
|
16
|
+
catalogDirty = false;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function selectSeeds() {
|
|
23
|
+
if (snapshot.kind === "seed")
|
|
24
|
+
return;
|
|
25
|
+
snapshot = { kind: "seed", models: SEED_MODELS };
|
|
26
|
+
catalogDirty = true;
|
|
27
|
+
}
|
|
28
|
+
async function refreshOnce() {
|
|
29
|
+
await dependencies.restoreCredential();
|
|
30
|
+
const apiKey = await dependencies.resolveApiKey();
|
|
31
|
+
if (apiKey === undefined) {
|
|
32
|
+
selectSeeds();
|
|
33
|
+
await reloadDirtyCatalog();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
snapshot = { kind: "listed", models: parseListedModels(await listModels(apiKey)) };
|
|
38
|
+
catalogDirty = true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof AuthenticationError) {
|
|
42
|
+
dependencies.rejectCredential();
|
|
43
|
+
selectSeeds();
|
|
21
44
|
}
|
|
45
|
+
}
|
|
46
|
+
await reloadDirtyCatalog();
|
|
47
|
+
}
|
|
48
|
+
function startDrain() {
|
|
49
|
+
return (async () => {
|
|
22
50
|
try {
|
|
23
|
-
|
|
51
|
+
do {
|
|
52
|
+
trailingRefresh = false;
|
|
53
|
+
await refreshOnce();
|
|
54
|
+
} while (trailingRefresh);
|
|
24
55
|
}
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
finally {
|
|
57
|
+
activeDrain = undefined;
|
|
27
58
|
}
|
|
59
|
+
})();
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
list() {
|
|
63
|
+
return snapshot.models;
|
|
64
|
+
},
|
|
65
|
+
refresh() {
|
|
66
|
+
if (closed)
|
|
67
|
+
return Promise.resolve();
|
|
68
|
+
if (activeDrain !== undefined) {
|
|
69
|
+
trailingRefresh = true;
|
|
70
|
+
return activeDrain;
|
|
71
|
+
}
|
|
72
|
+
activeDrain = startDrain();
|
|
73
|
+
return activeDrain;
|
|
74
|
+
},
|
|
75
|
+
close() {
|
|
76
|
+
if (closing !== undefined)
|
|
77
|
+
return closing;
|
|
78
|
+
closed = true;
|
|
79
|
+
closing = activeDrain ?? Promise.resolve();
|
|
80
|
+
return closing;
|
|
28
81
|
},
|
|
29
82
|
};
|
|
30
83
|
}
|
|
@@ -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
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import { Plugin } from "@opencode-ai/plugin";
|
|
2
2
|
export declare const plugin: Plugin.Plugin;
|
|
3
|
+
export interface ModelWatcherDependencies {
|
|
4
|
+
onLinked(listener: () => void): () => void;
|
|
5
|
+
subscribe(signal: AbortSignal): AsyncIterable<{
|
|
6
|
+
readonly type: string;
|
|
7
|
+
readonly data?: unknown;
|
|
8
|
+
}>;
|
|
9
|
+
onEvent?(event: {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly data?: unknown;
|
|
12
|
+
}): Promise<void> | void;
|
|
13
|
+
refresh(): Promise<void>;
|
|
14
|
+
close(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
export declare function watchModels(dependencies: ModelWatcherDependencies): () => Promise<void>;
|
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;
|
|
@@ -13,7 +13,12 @@ export const plugin = Plugin.define({
|
|
|
13
13
|
id: "opencode-cursor-provider",
|
|
14
14
|
async setup(ctx) {
|
|
15
15
|
const link = createCursorLink();
|
|
16
|
-
const models = createModelSource(
|
|
16
|
+
const models = createModelSource({
|
|
17
|
+
restoreCredential: () => link.restore(ctx),
|
|
18
|
+
resolveApiKey: () => link.resolve(),
|
|
19
|
+
rejectCredential: () => link.reject(),
|
|
20
|
+
reloadCatalog: () => ctx.catalog.reload(),
|
|
21
|
+
});
|
|
17
22
|
const bridge = createSessionAgentBridge({
|
|
18
23
|
link,
|
|
19
24
|
models: () => models.list(),
|
|
@@ -37,7 +42,6 @@ export const plugin = Plugin.define({
|
|
|
37
42
|
applyModels(draft, models.list());
|
|
38
43
|
}),
|
|
39
44
|
await ctx.session.hook("context", async (event) => {
|
|
40
|
-
event.tools = {};
|
|
41
45
|
if (String(event.agent) === "compaction" || String(event.agent) === "title")
|
|
42
46
|
return;
|
|
43
47
|
const stamped = bridge.annotate(event.system.map((part) => part.text), sessionFromId(event.sessionID, await sessionCwd(ctx, event.sessionID)));
|
|
@@ -54,9 +58,20 @@ export const plugin = Plugin.define({
|
|
|
54
58
|
event.language = languageModel(event.model, event.model.id, event.options);
|
|
55
59
|
}, { providerID: PROVIDER_ID }),
|
|
56
60
|
];
|
|
57
|
-
const stopRefresh = watchModels(
|
|
61
|
+
const stopRefresh = watchModels({
|
|
62
|
+
onLinked: (listener) => link.onLinked(listener),
|
|
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
|
+
},
|
|
69
|
+
refresh: () => models.refresh(),
|
|
70
|
+
close: () => models.close(),
|
|
71
|
+
});
|
|
58
72
|
return async () => {
|
|
59
|
-
stopRefresh();
|
|
73
|
+
await stopRefresh();
|
|
74
|
+
await bridge.dispose();
|
|
60
75
|
for (const registration of registrations.toReversed()) {
|
|
61
76
|
await registration.dispose();
|
|
62
77
|
}
|
|
@@ -94,16 +109,21 @@ function writeSystem(system, stamped) {
|
|
|
94
109
|
system[index] = { type: "text", text };
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
|
-
function watchModels(
|
|
112
|
+
export function watchModels(dependencies) {
|
|
98
113
|
const events = new AbortController();
|
|
114
|
+
let stopped = false;
|
|
115
|
+
let cleanup;
|
|
99
116
|
const refresh = () => {
|
|
100
|
-
|
|
117
|
+
if (stopped)
|
|
118
|
+
return;
|
|
119
|
+
void dependencies.refresh().catch(() => { });
|
|
101
120
|
};
|
|
102
|
-
const stopLinked =
|
|
121
|
+
const stopLinked = dependencies.onLinked(refresh);
|
|
103
122
|
refresh();
|
|
104
|
-
|
|
123
|
+
const eventTask = (async () => {
|
|
105
124
|
try {
|
|
106
|
-
for await (const event of
|
|
125
|
+
for await (const event of dependencies.subscribe(events.signal)) {
|
|
126
|
+
await dependencies.onEvent?.(event);
|
|
107
127
|
if (event.type === "credential.updated" || event.type === "credential.switched")
|
|
108
128
|
refresh();
|
|
109
129
|
}
|
|
@@ -119,9 +139,24 @@ function watchModels(ctx, link, models) {
|
|
|
119
139
|
}, REFRESH_FOR_MS);
|
|
120
140
|
stop.unref?.();
|
|
121
141
|
return () => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
142
|
+
if (cleanup !== undefined)
|
|
143
|
+
return cleanup;
|
|
144
|
+
cleanup = (async () => {
|
|
145
|
+
stopped = true;
|
|
146
|
+
stopLinked();
|
|
147
|
+
events.abort();
|
|
148
|
+
clearInterval(retry);
|
|
149
|
+
clearTimeout(stop);
|
|
150
|
+
await eventTask;
|
|
151
|
+
await dependencies.close();
|
|
152
|
+
})();
|
|
153
|
+
return cleanup;
|
|
126
154
|
};
|
|
127
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,9 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-cursor-provider",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "OpenCode 2 plugin that connects Cursor via the official SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pragmaticivan/opencode-cursor-provider.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/pragmaticivan/opencode-cursor-provider/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/pragmaticivan/opencode-cursor-provider#readme",
|
|
7
15
|
"engines": {
|
|
8
16
|
"node": ">=22.13"
|
|
9
17
|
},
|