cursor-opencode-provider 0.6.7 → 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.
Files changed (42) hide show
  1. package/README.md +52 -180
  2. package/dist/compaction-marker.d.ts +3 -2
  3. package/dist/compaction-marker.js +3 -2
  4. package/dist/host-agent-mode.d.ts +32 -0
  5. package/dist/host-agent-mode.js +92 -0
  6. package/dist/language-model.d.ts +10 -1
  7. package/dist/language-model.js +253 -41
  8. package/dist/models.d.ts +12 -1
  9. package/dist/models.js +16 -6
  10. package/dist/opencode2/catalog.d.ts +46 -11
  11. package/dist/opencode2/catalog.js +60 -45
  12. package/dist/opencode2/todo-tools.d.ts +64 -0
  13. package/dist/opencode2/todo-tools.js +165 -0
  14. package/dist/opencode2/types.d.ts +138 -83
  15. package/dist/opencode2/types.js +8 -10
  16. package/dist/plan-execution-kickoff.d.ts +2 -0
  17. package/dist/plan-execution-kickoff.js +25 -1
  18. package/dist/plugin-opencode2.d.ts +4 -1
  19. package/dist/plugin-opencode2.js +335 -154
  20. package/dist/plugin.js +5 -1
  21. package/dist/protocol/conversation-persistence.d.ts +2 -0
  22. package/dist/protocol/conversation-persistence.js +19 -1
  23. package/dist/protocol/conversation-state.d.ts +4 -0
  24. package/dist/protocol/conversation-state.js +4 -0
  25. package/dist/protocol/messages.js +14 -1
  26. package/dist/protocol/switch-mode.d.ts +5 -4
  27. package/dist/protocol/switch-mode.js +12 -4
  28. package/dist/protocol/tool-call-bridge.d.ts +29 -1
  29. package/dist/protocol/tool-call-bridge.js +187 -7
  30. package/dist/protocol/tools.d.ts +30 -4
  31. package/dist/protocol/tools.js +185 -58
  32. package/dist/session.d.ts +13 -1
  33. package/dist/session.js +1 -0
  34. package/dist/shared.d.ts +3 -1
  35. package/dist/shared.js +3 -1
  36. package/dist/shell-timeout.d.ts +13 -0
  37. package/dist/shell-timeout.js +38 -0
  38. package/dist/todo-store.d.ts +29 -0
  39. package/dist/todo-store.js +78 -0
  40. package/dist/web-tools.d.ts +13 -0
  41. package/dist/web-tools.js +83 -5
  42. package/package.json +6 -3
@@ -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 { executeCursorImageSave, } from "./image-save.js";
9
- import { CURSOR_IMAGE_SAVE_TOOL } from "./protocol/generate-image.js";
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 beta plugin.
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
- * Load with: { "plugins": ["cursor-opencode-provider/plugin/opencode2"] }
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, { baseURL: cursorApiBaseURL() });
45
+ return await discoverModels(accessToken, cacheDir, {
46
+ baseURL: cursorApiBaseURL(),
47
+ forceRefresh,
48
+ });
33
49
  }
34
50
  catch {
35
- // Fall through to stale-on-failure below.
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
- // The 2.0 SessionDomain has `prompt` but no agent-selection method, so it
52
- // cannot faithfully reproduce classic plan_exit's `agent: "build"` kickoff.
53
- // Leave the handler absent: the continuation reports an execution error and
54
- // retains plan mode instead of returning false success.
55
- setPlanExecutionKickoff(undefined);
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
- // ── Credentials ──────────────────────────────────────────────────────────
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
- // ── Catalog ──────────────────────────────────────────────────────────────
85
- // Runs now with whatever models we have (likely none) and is replayed by the
86
- // host on every `catalog.reload()`, so the async fill below just re-triggers it.
87
- await track(ctx.catalog.transform((draft) => {
88
- applyCursorProvider(draft);
89
- applyCursorModels(draft, models);
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
- // ── AI SDK wiring ────────────────────────────────────────────────────────
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: process.cwd(),
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
- // Registered as a tool, matching the classic plugin. 2.0 also has a
122
- // first-class `websearch` domain, but its Result shape is structured
123
- // ({url, title?, content?}) while the Exa backend here returns an opaque
124
- // text blob — mapping one to the other would mean guessing Exa's payload
125
- // shape, so the tool form stays until that can be verified against a live
126
- // response.
127
- await track(ctx.tool.transform((draft) => {
128
- draft.add({
129
- name: "custom_websearch",
130
- description: "Search the web for current information using OpenCode's web search backend.",
131
- input: {
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
- required: ["image_id"],
170
- additionalProperties: false,
171
- },
172
- execute: async (input, context) => {
173
- const saveCtx = {
174
- worktree: typeof context?.worktree === "string" ? context.worktree : "",
175
- directory: typeof context?.directory === "string" ? context.directory : "",
176
- ask: typeof context?.ask === "function"
177
- ? context.ask.bind(context)
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 !== "bash")
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
- preferWrapperCommand: true,
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 !== "bash")
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
- result.title = cursorShellOriginalCommand(executionID) ?? result.title;
214
- result.output = captureCursorShellResult(executionID, result.output, result.metadata);
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
- // ── Compaction marker + session directory ───────────────────────────────
227
- // 2.0 removed `chat.params`, and `session.hook("context")` exposes no
228
- // provider-options channel — only `system`/`messages`/`tools` are mutable.
229
- // But it does name the owning agent and the session id, and the provider
230
- // already derives the same session id from request headers, so record
231
- // both facts here and let doStream read them back via
232
- // `isCompactionSession` / `getSessionDirectory`.
233
- await track(ctx.session.hook("context", async (event) => {
234
- markCompactionSession(event.sessionID, event.agent === "compaction");
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: event.sessionID });
237
- markSessionDirectory(event.sessionID, info.location?.directory);
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
- // ── Model discovery ──────────────────────────────────────────────────────
244
- // Idempotent and cheap once satisfied, so it is safe to call from anywhere
245
- // that might mark the moment credentials became available.
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
- ensureInflight ??= (async () => {
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
- models = discovered;
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
- // Replays the catalog transform above, this time with models.
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
- // ── Session activity ─────────────────────────────────────────────────────
294
- // Doubles as a retry trigger: any host activity after the polling window has
295
- // closed still gets one more chance to pick up newly-added credentials.
296
- const unsubscribe = subscribeSessionActivity(ctx, ensureModels);
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
- /** Feed OpenCode session/message events into the shared activity tracker. */
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 "session.created":
333
- sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
334
- sessionActivity.recordActivity(event.properties.info.id);
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
- sessionActivity.linkSession(event.properties.info.id, event.properties.info.parentID);
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
- case "session.deleted":
340
- sessionActivity.removeSession(event.properties.info.id);
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
- case "message.updated":
343
- sessionActivity.recordActivity(event.properties.info.sessionID);
496
+ }
497
+ case "message.updated": {
498
+ const id = payload?.sessionID ?? info?.sessionID;
499
+ if (id)
500
+ sessionActivity.recordActivity(id);
344
501
  break;
345
- case "message.part.updated":
346
- sessionActivity.recordActivity(event.properties.part.sessionID);
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;