cursor-opencode-provider 0.6.8 → 0.7.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 +50 -180
- package/dist/compaction-marker.d.ts +3 -2
- package/dist/compaction-marker.js +3 -2
- package/dist/host-agent-mode.d.ts +32 -0
- package/dist/host-agent-mode.js +92 -0
- package/dist/language-model.d.ts +6 -1
- package/dist/language-model.js +170 -34
- package/dist/models.d.ts +12 -1
- package/dist/models.js +16 -6
- package/dist/opencode2/catalog.d.ts +46 -11
- package/dist/opencode2/catalog.js +60 -45
- package/dist/opencode2/todo-tools.d.ts +64 -0
- package/dist/opencode2/todo-tools.js +165 -0
- package/dist/opencode2/types.d.ts +138 -83
- package/dist/opencode2/types.js +8 -10
- package/dist/plan-execution-kickoff.d.ts +2 -0
- package/dist/plan-execution-kickoff.js +25 -1
- package/dist/plugin-opencode2.d.ts +4 -1
- package/dist/plugin-opencode2.js +335 -154
- package/dist/plugin.js +5 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +19 -1
- package/dist/protocol/conversation-state.d.ts +4 -0
- package/dist/protocol/conversation-state.js +4 -0
- package/dist/protocol/switch-mode.d.ts +5 -4
- package/dist/protocol/switch-mode.js +12 -4
- package/dist/protocol/tool-call-bridge.js +1 -1
- package/dist/protocol/tools.d.ts +30 -4
- package/dist/protocol/tools.js +185 -58
- package/dist/session.d.ts +7 -1
- package/dist/shared.d.ts +3 -1
- package/dist/shared.js +3 -1
- package/dist/shell-timeout.d.ts +13 -0
- package/dist/shell-timeout.js +38 -0
- package/dist/todo-store.d.ts +29 -0
- package/dist/todo-store.js +78 -0
- package/dist/web-tools.d.ts +13 -0
- package/dist/web-tools.js +83 -5
- package/package.json +6 -3
package/dist/plugin-opencode2.js
CHANGED
|
@@ -1,40 +1,61 @@
|
|
|
1
|
-
import { CURSOR_PROVIDER_ID } from "./shared.js";
|
|
1
|
+
import { CURSOR_PROVIDER_ID, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, } from "./shared.js";
|
|
2
2
|
import { createSdk, cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled, isCursorPackage } from "./plugin-core.js";
|
|
3
3
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
4
4
|
import { discoverModels, isCacheFresh, readCache } from "./models.js";
|
|
5
5
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
6
6
|
import { sessionActivity } from "./activity.js";
|
|
7
|
-
import { fetchOpenCodeWebSearchText } from "./web-tools.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { captureCursorShellResult, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
|
|
11
|
-
import { applyCursorModels, applyCursorProvider } from "./opencode2/catalog.js";
|
|
7
|
+
import { fetchOpenCodeWebSearchText, parseExaWebSearchResults, } from "./web-tools.js";
|
|
8
|
+
import { captureCursorShellResult, cursorShellEnvForCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
|
|
9
|
+
import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2/catalog.js";
|
|
12
10
|
import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
|
|
11
|
+
import { registerTodoTools } from "./opencode2/todo-tools.js";
|
|
12
|
+
import { clearSessionTodos } from "./todo-store.js";
|
|
13
13
|
import { markCompactionSession } from "./compaction-marker.js";
|
|
14
14
|
import { markSessionDirectory } from "./session-directory.js";
|
|
15
|
-
import { setPlanExecutionKickoff } from "./plan-execution-kickoff.js";
|
|
15
|
+
import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
|
|
16
|
+
import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
|
|
17
|
+
import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
|
|
18
|
+
import { CursorPlugin } from "./plugin.js";
|
|
16
19
|
/**
|
|
17
|
-
* OpenCode 2.0
|
|
20
|
+
* OpenCode 2.0 plugin.
|
|
18
21
|
*
|
|
19
22
|
* Separate from `plugin-v2.ts` on purpose: the OpenCode 1.18 `/v2/promise` API
|
|
20
23
|
* and the 2.0 API are source-incompatible (hook signatures, OAuth value type,
|
|
21
24
|
* provider schema), so they cannot share an entrypoint. Shared behavior lives in
|
|
22
25
|
* `plugin-core.ts`, `model-config.ts`, and `opencode2/*`.
|
|
23
26
|
*
|
|
24
|
-
*
|
|
27
|
+
* Models register in memory via `ctx.provider.transform` + `editor.add` +
|
|
28
|
+
* `reload()`. Nothing is written into `opencode.json`.
|
|
29
|
+
*
|
|
30
|
+
* Dual export: `{ id, setup, server: CursorPlugin }`. OpenCode 2.0 Host.resolve
|
|
31
|
+
* loads `./server` then `setup()`. OpenCode 1.18 also prefers `exports["./server"]`
|
|
32
|
+
* and then calls `server()` so classic 1.x hooks still run.
|
|
33
|
+
*
|
|
34
|
+
* Load with: { "plugin": ["cursor-opencode-provider/plugin/opencode2"] }
|
|
35
|
+
* or a local package directory under `$OPENCODE_CONFIG_DIR/plugins/` that
|
|
36
|
+
* re-exports `dist/plugin-opencode2.js` (OpenCode 2.0 requires a directory,
|
|
37
|
+
* not a .js path).
|
|
25
38
|
*/
|
|
26
|
-
async function loadModels(cacheDir, accessToken) {
|
|
39
|
+
async function loadModels(cacheDir, accessToken, forceRefresh = false) {
|
|
27
40
|
const cached = await readCache(cacheDir);
|
|
28
|
-
if (cached?.models.length && isCacheFresh(cached))
|
|
41
|
+
if (!forceRefresh && cached?.models.length && isCacheFresh(cached))
|
|
29
42
|
return cached.models;
|
|
30
43
|
if (accessToken) {
|
|
31
44
|
try {
|
|
32
|
-
return await discoverModels(accessToken, cacheDir, {
|
|
45
|
+
return await discoverModels(accessToken, cacheDir, {
|
|
46
|
+
baseURL: cursorApiBaseURL(),
|
|
47
|
+
forceRefresh,
|
|
48
|
+
});
|
|
33
49
|
}
|
|
34
50
|
catch {
|
|
35
|
-
//
|
|
51
|
+
// A forced refresh follows a credential switch. Do not bind a cache
|
|
52
|
+
// produced by the prior account to the new connection on failure.
|
|
53
|
+
if (forceRefresh)
|
|
54
|
+
return [];
|
|
36
55
|
}
|
|
37
56
|
}
|
|
57
|
+
if (forceRefresh)
|
|
58
|
+
return [];
|
|
38
59
|
// Preserve offline / stale-cache behavior rather than emptying the picker.
|
|
39
60
|
return cached?.models ?? [];
|
|
40
61
|
}
|
|
@@ -44,21 +65,77 @@ function toolExecutionID(event) {
|
|
|
44
65
|
throw new Error("OpenCode 2.0 tool hook did not provide an execution id");
|
|
45
66
|
return id;
|
|
46
67
|
}
|
|
68
|
+
function isShellTool(name) {
|
|
69
|
+
return name === "bash" || name === "shell";
|
|
70
|
+
}
|
|
71
|
+
function eventPayload(event) {
|
|
72
|
+
if (event?.data && typeof event.data === "object")
|
|
73
|
+
return event.data;
|
|
74
|
+
if (event?.properties && typeof event.properties === "object")
|
|
75
|
+
return event.properties;
|
|
76
|
+
return event;
|
|
77
|
+
}
|
|
78
|
+
function markCompactionAndOptions(event, isCompaction) {
|
|
79
|
+
markCompactionSession(event.sessionID, isCompaction);
|
|
80
|
+
// Keep the request-local flag authoritative. Explicit false prevents a
|
|
81
|
+
// concurrent title/generate/primary request for the same session from
|
|
82
|
+
// inheriting the process-wide fallback marker.
|
|
83
|
+
event.options ??= {};
|
|
84
|
+
event.options[CURSOR_COMPACTION_OPTION] = isCompaction;
|
|
85
|
+
}
|
|
47
86
|
const plugin = {
|
|
48
87
|
id: "cursor.provider",
|
|
88
|
+
server: CursorPlugin,
|
|
49
89
|
setup: async (ctx) => {
|
|
50
90
|
const cacheDir = opencodeGlobalCacheDir();
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
const workspaceRoot = ctx.location?.directory || process.cwd();
|
|
92
|
+
const hasShellEnvHook = typeof ctx.shell?.hook === "function";
|
|
93
|
+
const admitPlanKickoff = typeof ctx.session.synthetic === "function"
|
|
94
|
+
? ctx.session.synthetic
|
|
95
|
+
: ctx.session.prompt;
|
|
96
|
+
if (typeof ctx.session.switchAgent === "function" && typeof admitPlanKickoff === "function") {
|
|
97
|
+
const switchAgent = ctx.session.switchAgent;
|
|
98
|
+
setPlanExecutionKickoff(async ({ sessionID, planPath }) => {
|
|
99
|
+
await switchAgent({ sessionID, agent: "build" });
|
|
100
|
+
try {
|
|
101
|
+
await admitPlanKickoff({
|
|
102
|
+
sessionID,
|
|
103
|
+
text: createPlanExecutionKickoffText(planPath),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// Switching and admitting input are separate public APIs. Restore the
|
|
108
|
+
// plan agent if admission fails so the shared retry state is honest:
|
|
109
|
+
// the plan remains active rather than silently leaving the session in
|
|
110
|
+
// build mode with no execution turn.
|
|
111
|
+
await switchAgent({ sessionID, agent: "plan" }).catch(() => { });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
setPlanExecutionKickoff(undefined);
|
|
118
|
+
}
|
|
119
|
+
if (typeof ctx.session.switchAgent === "function") {
|
|
120
|
+
const switchAgent = ctx.session.switchAgent;
|
|
121
|
+
setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
|
|
122
|
+
const mode = normalizeSwitchModeId(targetModeID);
|
|
123
|
+
await switchAgent({
|
|
124
|
+
sessionID,
|
|
125
|
+
agent: mode === "plan" || mode === "spec" ? "plan" : "build",
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
setHostAgentModeSwitch(undefined);
|
|
131
|
+
}
|
|
56
132
|
const registrations = [];
|
|
57
133
|
const track = async (p) => {
|
|
58
134
|
registrations.push(await p);
|
|
59
135
|
};
|
|
60
136
|
let models = [];
|
|
61
|
-
|
|
137
|
+
let sourceConnection;
|
|
138
|
+
// ── Credentials ─────────────────────────────────────────
|
|
62
139
|
await track(ctx.integration.transform(applyCursorIntegration));
|
|
63
140
|
let cachedToken;
|
|
64
141
|
let tokenInflight;
|
|
@@ -81,14 +158,50 @@ const plugin = {
|
|
|
81
158
|
cachedToken = token;
|
|
82
159
|
return token;
|
|
83
160
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
161
|
+
const refreshSourceConnection = async () => {
|
|
162
|
+
try {
|
|
163
|
+
sourceConnection = await ctx.integration.connection.active(CURSOR_INTEGRATION_ID);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
sourceConnection = undefined;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
// ── Provider inventory (in-memory `editor.add`) ─────────────────────
|
|
170
|
+
// Transform replays on provider.reload(). Skip while `models` is empty so
|
|
171
|
+
// the first registration is a no-op; discovery/cache then reload.
|
|
172
|
+
await track(ctx.provider.transform((editor) => {
|
|
173
|
+
applyCursorProviderInventory(editor, models, sourceConnection);
|
|
90
174
|
}));
|
|
91
|
-
|
|
175
|
+
try {
|
|
176
|
+
const cached = await readCache(cacheDir);
|
|
177
|
+
if (cached?.models?.length) {
|
|
178
|
+
models = cached.models;
|
|
179
|
+
await refreshSourceConnection();
|
|
180
|
+
await ctx.provider.reload();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Cache seed is best-effort; auth/aisdk still work without it.
|
|
185
|
+
}
|
|
186
|
+
const publishModels = async (next) => {
|
|
187
|
+
const previousModels = models;
|
|
188
|
+
const previousConnection = sourceConnection;
|
|
189
|
+
models = next;
|
|
190
|
+
await refreshSourceConnection();
|
|
191
|
+
try {
|
|
192
|
+
await ctx.provider.reload();
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Keep the transform backed by the last inventory that actually
|
|
197
|
+
// reloaded. A later host replay must not publish a failed account
|
|
198
|
+
// refresh merely because the candidate remained in this closure.
|
|
199
|
+
models = previousModels;
|
|
200
|
+
sourceConnection = previousConnection;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
// ── AI SDK wiring ────────────────────────────────────────
|
|
92
205
|
await track(ctx.aisdk.hook("sdk", async (event) => {
|
|
93
206
|
if (event.sdk)
|
|
94
207
|
return;
|
|
@@ -102,7 +215,7 @@ const plugin = {
|
|
|
102
215
|
// per session, and 2.0 runs one daemon across many projects — the
|
|
103
216
|
// real per-request directory comes from the session.context hook
|
|
104
217
|
// below via `getSessionDirectory`, which `language-model.ts` prefers.
|
|
105
|
-
workspaceRoot
|
|
218
|
+
workspaceRoot,
|
|
106
219
|
cacheDir,
|
|
107
220
|
...event.options,
|
|
108
221
|
});
|
|
@@ -117,101 +230,79 @@ const plugin = {
|
|
|
117
230
|
// `modelID` is the Cursor wire id; `id` may be a synthetic long-context entry.
|
|
118
231
|
event.language = event.sdk.languageModel(event.model.modelID || event.model.id);
|
|
119
232
|
}));
|
|
120
|
-
// ── Web search
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
type: "object",
|
|
133
|
-
properties: {
|
|
134
|
-
query: { type: "string", description: "Web search query" },
|
|
135
|
-
numResults: { type: "integer", minimum: 1, maximum: 20 },
|
|
136
|
-
livecrawl: { type: "string", enum: ["fallback", "preferred"] },
|
|
137
|
-
type: { type: "string", enum: ["auto", "fast", "deep"] },
|
|
138
|
-
contextMaxCharacters: { type: "integer", exclusiveMinimum: 0 },
|
|
139
|
-
},
|
|
140
|
-
required: ["query"],
|
|
141
|
-
additionalProperties: false,
|
|
142
|
-
},
|
|
143
|
-
execute: async (input, context) => {
|
|
144
|
-
const output = await fetchOpenCodeWebSearchText(input, context?.abort);
|
|
145
|
-
return {
|
|
146
|
-
title: `Exa Web Search: ${input.query}`,
|
|
147
|
-
output,
|
|
148
|
-
metadata: { provider: "exa" },
|
|
149
|
-
};
|
|
150
|
-
},
|
|
151
|
-
});
|
|
152
|
-
// Same handle-only commit tool as classic plugin.ts. Uses host-neutral
|
|
153
|
-
// executeCursorImageSave (not image-save-tool.ts) so this entrypoint never
|
|
154
|
-
// imports `@opencode-ai/plugin`'s classic `tool()` helper.
|
|
155
|
-
draft.add({
|
|
156
|
-
name: CURSOR_IMAGE_SAVE_TOOL,
|
|
157
|
-
description: "Save an image that Cursor generated during this session to its target path. "
|
|
158
|
-
+ "Takes only the id of an already-generated image — it cannot write arbitrary "
|
|
159
|
-
+ "files, and it is not a general-purpose file writer. You do not normally call "
|
|
160
|
-
+ "this: the Cursor provider issues it after an image is generated.",
|
|
161
|
-
input: {
|
|
162
|
-
type: "object",
|
|
163
|
-
properties: {
|
|
164
|
-
image_id: {
|
|
165
|
-
type: "string",
|
|
166
|
-
description: "Id of the pending Cursor-generated image to save",
|
|
167
|
-
},
|
|
233
|
+
// ── Web search ───────────────────────────────────────────
|
|
234
|
+
// Publish an OpenCode 2.0 `websearch` provider (`{url,title,content,time}`).
|
|
235
|
+
// The classic entrypoint owns the permission-aware `custom_websearch`
|
|
236
|
+
// fallback; 2.0's public plugin tool context cannot request permission.
|
|
237
|
+
if (ctx.websearch) {
|
|
238
|
+
await track(ctx.websearch.transform((draft) => {
|
|
239
|
+
draft.add({
|
|
240
|
+
id: "cursor-exa",
|
|
241
|
+
name: "Exa",
|
|
242
|
+
execute: async (input, context) => {
|
|
243
|
+
const output = await fetchOpenCodeWebSearchText({ query: input.query }, context.signal);
|
|
244
|
+
return parseExaWebSearchResults(output);
|
|
168
245
|
},
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
: async () => {
|
|
179
|
-
throw new Error("OpenCode 2.0 tool context did not provide ask(); cannot gate image save");
|
|
180
|
-
},
|
|
181
|
-
};
|
|
182
|
-
const result = await executeCursorImageSave(input, saveCtx);
|
|
183
|
-
if (typeof result === "string") {
|
|
184
|
-
return { title: CURSOR_IMAGE_SAVE_TOOL, output: result, metadata: {} };
|
|
185
|
-
}
|
|
186
|
-
const typed = result;
|
|
187
|
-
return {
|
|
188
|
-
title: typed.title,
|
|
189
|
-
output: typed.output,
|
|
190
|
-
metadata: {},
|
|
191
|
-
};
|
|
192
|
-
},
|
|
193
|
-
});
|
|
246
|
+
});
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
await track(ctx.tool.transform((draft) => {
|
|
250
|
+
// OpenCode 2 dropped host todowrite/todoread. Off by default
|
|
251
|
+
// (`CURSOR_OPENCODE2_TODOS=1`/`true` force-enables). When on, register
|
|
252
|
+
// them as direct catalog tools (`codemode: false` + output schema) if
|
|
253
|
+
// the host does not already own those names. When off, register none.
|
|
254
|
+
registerTodoTools(draft);
|
|
194
255
|
}));
|
|
195
|
-
// ── Shell timeout wrapper
|
|
256
|
+
// ── Shell timeout wrapper ────────────────────────────────
|
|
196
257
|
await track(ctx.tool.hook("execute.before", (event) => {
|
|
197
|
-
if (event.tool
|
|
258
|
+
if (!isShellTool(event.tool))
|
|
198
259
|
return;
|
|
199
260
|
const executionID = toolExecutionID(event);
|
|
200
|
-
// No `shell.env` in 2.0 → always use the wrapper-file command form.
|
|
201
261
|
prepareCursorShellArgs(executionID, event.input, {
|
|
202
|
-
|
|
262
|
+
// OpenCode 2.0 `shell.create.before` can inject env for bash/zsh.
|
|
263
|
+
preferWrapperCommand: !hasShellEnvHook,
|
|
203
264
|
});
|
|
204
265
|
}));
|
|
205
266
|
await track(ctx.tool.hook("execute.after", (event) => {
|
|
206
|
-
if (event.tool
|
|
267
|
+
if (!isShellTool(event.tool))
|
|
207
268
|
return;
|
|
208
269
|
const executionID = toolExecutionID(event);
|
|
209
270
|
try {
|
|
210
271
|
if (event.status !== "completed")
|
|
211
272
|
return;
|
|
212
273
|
const result = event.result;
|
|
213
|
-
|
|
214
|
-
|
|
274
|
+
// V1: `output` is the model-facing string. V2: `output` is structured
|
|
275
|
+
// per-tool output (shell: `{ output: string, ... }`) and the
|
|
276
|
+
// model-facing text lives on `content`. Sanitize every string
|
|
277
|
+
// location; non-strings pass through via the guards in shell-timeout.
|
|
278
|
+
if (typeof result.output === "string") {
|
|
279
|
+
result.output = captureCursorShellResult(executionID, result.output, result.metadata);
|
|
280
|
+
}
|
|
281
|
+
else if (result.output && typeof result.output === "object") {
|
|
282
|
+
const structured = result.output;
|
|
283
|
+
if (typeof structured.output === "string") {
|
|
284
|
+
structured.output = captureCursorShellResult(executionID, structured.output, result.metadata);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (typeof result.content === "string") {
|
|
288
|
+
result.content = sanitizeRegisteredCursorShellOutput(executionID, result.content);
|
|
289
|
+
}
|
|
290
|
+
else if (Array.isArray(result.content)) {
|
|
291
|
+
result.content = result.content.map((item) => {
|
|
292
|
+
if (!item || typeof item !== "object")
|
|
293
|
+
return item;
|
|
294
|
+
const content = item;
|
|
295
|
+
if (content.type !== "text" || typeof content.text !== "string")
|
|
296
|
+
return item;
|
|
297
|
+
return {
|
|
298
|
+
...content,
|
|
299
|
+
text: sanitizeRegisteredCursorShellOutput(executionID, content.text),
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
else if (typeof result.output === "string" && result.content === undefined) {
|
|
304
|
+
result.content = result.output;
|
|
305
|
+
}
|
|
215
306
|
if (result.metadata && typeof result.metadata === "object") {
|
|
216
307
|
const metadata = result.metadata;
|
|
217
308
|
if (typeof metadata.output === "string") {
|
|
@@ -223,45 +314,88 @@ const plugin = {
|
|
|
223
314
|
releaseCursorShellEnv(executionID);
|
|
224
315
|
}
|
|
225
316
|
}));
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
317
|
+
if (ctx.shell) {
|
|
318
|
+
await track(ctx.shell.hook("create.before", (event) => {
|
|
319
|
+
const env = cursorShellEnvForCommand(event.command, event.cwd);
|
|
320
|
+
if (!env)
|
|
321
|
+
return;
|
|
322
|
+
event.env = { ...event.env, ...env };
|
|
323
|
+
}));
|
|
324
|
+
}
|
|
325
|
+
const rememberSessionDirectory = async (sessionID) => {
|
|
235
326
|
try {
|
|
236
|
-
const info = await ctx.session.get({ sessionID
|
|
237
|
-
markSessionDirectory(
|
|
327
|
+
const info = await ctx.session.get({ sessionID });
|
|
328
|
+
markSessionDirectory(sessionID, info.location?.directory);
|
|
238
329
|
}
|
|
239
330
|
catch {
|
|
240
331
|
// Best effort — falls back to the static workspaceRoot above.
|
|
241
332
|
}
|
|
333
|
+
};
|
|
334
|
+
await track(ctx.session.hook("context", async (event) => {
|
|
335
|
+
markCompactionAndOptions(event, event.agent === "compaction");
|
|
336
|
+
event.options ??= {};
|
|
337
|
+
event.options[CURSOR_HOST_AGENT_OPTION] = event.agent;
|
|
338
|
+
if (event.agent !== "compaction") {
|
|
339
|
+
const activeMode = getActiveCursorMode(event.sessionID);
|
|
340
|
+
if (event.agent === "plan") {
|
|
341
|
+
if (activeMode !== "plan" && activeMode !== "spec") {
|
|
342
|
+
setActiveCursorMode(event.sessionID, "plan");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
else if (activeMode === "plan" || activeMode === "spec") {
|
|
346
|
+
// A direct OpenCode UI switch away from Plan is authoritative. Do
|
|
347
|
+
// not overwrite other Cursor-only modes (chat/debug/etc.) merely
|
|
348
|
+
// because their closest native primary agent is `build`.
|
|
349
|
+
setActiveCursorMode(event.sessionID, "agent");
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
await rememberSessionDirectory(event.sessionID);
|
|
242
353
|
}));
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
354
|
+
await track(ctx.session.hook("compaction", async (event) => {
|
|
355
|
+
markCompactionAndOptions(event, true);
|
|
356
|
+
await rememberSessionDirectory(event.sessionID);
|
|
357
|
+
}));
|
|
358
|
+
await track(ctx.session.hook("generate", async (event) => {
|
|
359
|
+
markCompactionAndOptions(event, false);
|
|
360
|
+
await rememberSessionDirectory(event.sessionID);
|
|
361
|
+
}));
|
|
362
|
+
await track(ctx.session.hook("title", async (event) => {
|
|
363
|
+
markCompactionAndOptions(event, false);
|
|
364
|
+
await rememberSessionDirectory(event.sessionID);
|
|
365
|
+
}));
|
|
366
|
+
// ── Model discovery ────────────────────────────────────────
|
|
246
367
|
let modelsLoaded = false;
|
|
368
|
+
let credentialGeneration = 0;
|
|
369
|
+
let loadedCredentialGeneration = 0;
|
|
247
370
|
let ensureInflight;
|
|
248
371
|
const ensureModels = () => {
|
|
249
|
-
if (modelsLoaded)
|
|
372
|
+
if (modelsLoaded && loadedCredentialGeneration === credentialGeneration) {
|
|
250
373
|
return Promise.resolve();
|
|
251
|
-
|
|
374
|
+
}
|
|
375
|
+
// If credentials change during an existing discovery, wait for that
|
|
376
|
+
// attempt and immediately run again. The generation check prevents the
|
|
377
|
+
// older attempt from suppressing the account-scoped refresh.
|
|
378
|
+
if (ensureInflight)
|
|
379
|
+
return ensureInflight.then(() => ensureModels());
|
|
380
|
+
const attemptGeneration = credentialGeneration;
|
|
381
|
+
const forceRefresh = loadedCredentialGeneration !== attemptGeneration;
|
|
382
|
+
ensureInflight = (async () => {
|
|
252
383
|
try {
|
|
253
384
|
const token = await accessToken();
|
|
254
|
-
const discovered = await loadModels(cacheDir, token);
|
|
385
|
+
const discovered = await loadModels(cacheDir, token, forceRefresh);
|
|
255
386
|
if (!discovered.length)
|
|
256
387
|
return;
|
|
257
|
-
|
|
388
|
+
if (credentialGeneration !== attemptGeneration)
|
|
389
|
+
return;
|
|
390
|
+
if (!await publishModels(discovered))
|
|
391
|
+
return;
|
|
392
|
+
// A newer credential event arrived while this request was in flight.
|
|
393
|
+
// Keep the state dirty so the chained ensure uses the latest token.
|
|
394
|
+
if (credentialGeneration !== attemptGeneration)
|
|
395
|
+
return;
|
|
258
396
|
modelsLoaded = true;
|
|
259
|
-
|
|
260
|
-
await ctx.catalog.reload().catch(() => { });
|
|
397
|
+
loadedCredentialGeneration = attemptGeneration;
|
|
261
398
|
if (token) {
|
|
262
|
-
// Resolve the region-specific Run origin so the first turn doesn't
|
|
263
|
-
// pay for GetServerConfig. Best effort: startSession surfaces real
|
|
264
|
-
// failures.
|
|
265
399
|
await resolveAgentUrl(token, {
|
|
266
400
|
apiBaseURL: cursorApiBaseURL(),
|
|
267
401
|
telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
|
|
@@ -274,10 +408,6 @@ const plugin = {
|
|
|
274
408
|
})();
|
|
275
409
|
return ensureInflight;
|
|
276
410
|
};
|
|
277
|
-
// On a fresh install the user connects *after* startup, and the host has no
|
|
278
|
-
// "connection established" hook we can key on. Poll briefly so models show
|
|
279
|
-
// up within seconds of /connect instead of requiring a restart. The window
|
|
280
|
-
// is bounded; after it, any session/message event still triggers a retry.
|
|
281
411
|
const RETRY_INTERVAL_MS = 3_000;
|
|
282
412
|
const RETRY_WINDOW_MS = 300_000;
|
|
283
413
|
const startedAt = Date.now();
|
|
@@ -286,26 +416,28 @@ const plugin = {
|
|
|
286
416
|
clearInterval(retry);
|
|
287
417
|
return;
|
|
288
418
|
}
|
|
289
|
-
void ensureModels();
|
|
419
|
+
void ensureModels().catch(() => { });
|
|
290
420
|
}, RETRY_INTERVAL_MS);
|
|
291
421
|
retry.unref?.();
|
|
292
|
-
void ensureModels();
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
422
|
+
void ensureModels().catch(() => { });
|
|
423
|
+
const onCredentialSwitch = () => {
|
|
424
|
+
cachedToken = undefined;
|
|
425
|
+
modelsLoaded = false;
|
|
426
|
+
credentialGeneration++;
|
|
427
|
+
};
|
|
428
|
+
const unsubscribe = subscribeSessionActivity(ctx, ensureModels, onCredentialSwitch);
|
|
297
429
|
return async () => {
|
|
298
430
|
clearInterval(retry);
|
|
299
431
|
unsubscribe?.();
|
|
300
432
|
setPlanExecutionKickoff(undefined);
|
|
433
|
+
setHostAgentModeSwitch(undefined);
|
|
301
434
|
for (const registration of registrations.reverse()) {
|
|
302
435
|
await registration.dispose().catch(() => { });
|
|
303
436
|
}
|
|
304
437
|
};
|
|
305
438
|
},
|
|
306
439
|
};
|
|
307
|
-
|
|
308
|
-
function subscribeSessionActivity(ctx, onEvent) {
|
|
440
|
+
function subscribeSessionActivity(ctx, onEvent, onCredentialSwitch) {
|
|
309
441
|
try {
|
|
310
442
|
const stream = ctx.event.subscribe();
|
|
311
443
|
if (!stream || typeof stream[Symbol.asyncIterator] !== "function")
|
|
@@ -315,7 +447,7 @@ function subscribeSessionActivity(ctx, onEvent) {
|
|
|
315
447
|
for await (const event of stream) {
|
|
316
448
|
if (stopped)
|
|
317
449
|
break;
|
|
318
|
-
applySessionActivity(event);
|
|
450
|
+
applySessionActivity(event, onCredentialSwitch);
|
|
319
451
|
onEvent?.();
|
|
320
452
|
}
|
|
321
453
|
})().catch(() => { });
|
|
@@ -327,24 +459,73 @@ function subscribeSessionActivity(ctx, onEvent) {
|
|
|
327
459
|
return undefined;
|
|
328
460
|
}
|
|
329
461
|
}
|
|
330
|
-
function applySessionActivity(event) {
|
|
462
|
+
function applySessionActivity(event, onCredentialSwitch) {
|
|
463
|
+
const payload = eventPayload(event);
|
|
464
|
+
const info = payload?.info;
|
|
331
465
|
switch (event?.type) {
|
|
332
|
-
case "
|
|
333
|
-
|
|
334
|
-
|
|
466
|
+
case "credential.switched":
|
|
467
|
+
case "credential.updated": {
|
|
468
|
+
const integrationID = payload?.integrationID;
|
|
469
|
+
if (!integrationID || integrationID === CURSOR_INTEGRATION_ID || integrationID === CURSOR_PROVIDER_ID) {
|
|
470
|
+
onCredentialSwitch?.();
|
|
471
|
+
}
|
|
335
472
|
break;
|
|
473
|
+
}
|
|
474
|
+
case "session.created":
|
|
336
475
|
case "session.updated":
|
|
337
|
-
|
|
476
|
+
case "session.forked": {
|
|
477
|
+
const id = payload?.sessionID ?? info?.id;
|
|
478
|
+
const parentID = payload?.parentID ?? info?.parentID;
|
|
479
|
+
if (id) {
|
|
480
|
+
sessionActivity.linkSession(id, parentID);
|
|
481
|
+
if (event.type === "session.created")
|
|
482
|
+
sessionActivity.recordActivity(id);
|
|
483
|
+
}
|
|
338
484
|
break;
|
|
339
|
-
|
|
340
|
-
|
|
485
|
+
}
|
|
486
|
+
case "session.deleted": {
|
|
487
|
+
const id = payload?.sessionID ?? info?.id;
|
|
488
|
+
if (id) {
|
|
489
|
+
sessionActivity.removeSession(id);
|
|
490
|
+
clearSessionTodos(id);
|
|
491
|
+
clearActiveCursorMode(id);
|
|
492
|
+
cancelPlanExecutionKickoff(id);
|
|
493
|
+
cancelHostAgentModeSwitch(id);
|
|
494
|
+
}
|
|
341
495
|
break;
|
|
342
|
-
|
|
343
|
-
|
|
496
|
+
}
|
|
497
|
+
case "message.updated": {
|
|
498
|
+
const id = payload?.sessionID ?? info?.sessionID;
|
|
499
|
+
if (id)
|
|
500
|
+
sessionActivity.recordActivity(id);
|
|
344
501
|
break;
|
|
345
|
-
|
|
346
|
-
|
|
502
|
+
}
|
|
503
|
+
case "message.part.updated": {
|
|
504
|
+
const id = payload?.sessionID ?? payload?.part?.sessionID ?? info?.sessionID;
|
|
505
|
+
if (id)
|
|
506
|
+
sessionActivity.recordActivity(id);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
case "session.usage.updated":
|
|
510
|
+
case "session.usage.recorded": {
|
|
511
|
+
const id = payload?.sessionID;
|
|
512
|
+
if (id)
|
|
513
|
+
sessionActivity.recordActivity(id);
|
|
347
514
|
break;
|
|
515
|
+
}
|
|
516
|
+
default: {
|
|
517
|
+
// OpenCode 2.0 emits granular `session.*` progress events instead of
|
|
518
|
+
// the V1 `message.updated` family (`session.tool.called/success/failed`,
|
|
519
|
+
// `session.step.*`, `session.text.*`, `session.execution.*`, ...). Any of
|
|
520
|
+
// them proves the session is alive and renews a pending-tool lease.
|
|
521
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
522
|
+
if (type.startsWith("session.") && type !== "session.deleted") {
|
|
523
|
+
const id = payload?.sessionID ?? info?.sessionID ?? info?.id;
|
|
524
|
+
if (id)
|
|
525
|
+
sessionActivity.recordActivity(id);
|
|
526
|
+
}
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
348
529
|
}
|
|
349
530
|
}
|
|
350
531
|
export default plugin;
|