cursor-opencode-provider 0.7.6 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -15
  3. package/SECURITY.md +43 -7
  4. package/dist/agent-url.js +2 -2
  5. package/dist/auth-renewal.d.ts +85 -0
  6. package/dist/auth-renewal.js +284 -0
  7. package/dist/auth.d.ts +38 -22
  8. package/dist/auth.js +79 -96
  9. package/dist/classic-tools.d.ts +15 -0
  10. package/dist/classic-tools.js +45 -0
  11. package/dist/context/build.d.ts +62 -7
  12. package/dist/context/build.js +127 -95
  13. package/dist/context/dynamic-catalog.d.ts +2 -58
  14. package/dist/context/dynamic-catalog.js +16 -161
  15. package/dist/context/epoch.d.ts +6 -4
  16. package/dist/context/epoch.js +22 -10
  17. package/dist/context/frozen.js +42 -15
  18. package/dist/context/index.d.ts +3 -1
  19. package/dist/context/index.js +3 -1
  20. package/dist/context/overlay.d.ts +2 -23
  21. package/dist/context/overlay.js +6 -30
  22. package/dist/context/paths.d.ts +51 -21
  23. package/dist/context/paths.js +78 -27
  24. package/dist/context/rules.d.ts +3 -15
  25. package/dist/context/rules.js +10 -186
  26. package/dist/context/skills-bridge.d.ts +22 -0
  27. package/dist/context/skills-bridge.js +20 -0
  28. package/dist/context/skills.d.ts +31 -8
  29. package/dist/context/skills.js +187 -86
  30. package/dist/debug.d.ts +2 -0
  31. package/dist/debug.js +4 -0
  32. package/dist/errors.d.ts +13 -0
  33. package/dist/errors.js +27 -3
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +119 -15
  36. package/dist/host-plan-file.d.ts +28 -0
  37. package/dist/host-plan-file.js +76 -0
  38. package/dist/image-input.d.ts +18 -0
  39. package/dist/image-input.js +119 -28
  40. package/dist/image-save.d.ts +10 -2
  41. package/dist/image-save.js +35 -23
  42. package/dist/index.d.ts +10 -0
  43. package/dist/language-model.d.ts +86 -25
  44. package/dist/language-model.js +1295 -369
  45. package/dist/model-config.d.ts +9 -0
  46. package/dist/model-config.js +28 -1
  47. package/dist/opencode2/catalog.d.ts +1 -0
  48. package/dist/opencode2/catalog.js +2 -0
  49. package/dist/opencode2/image-save-tool.d.ts +8 -0
  50. package/dist/opencode2/image-save-tool.js +84 -0
  51. package/dist/opencode2/integration.d.ts +30 -7
  52. package/dist/opencode2/integration.js +75 -46
  53. package/dist/opencode2/types.d.ts +15 -0
  54. package/dist/parallel-step.d.ts +65 -0
  55. package/dist/parallel-step.js +102 -0
  56. package/dist/plugin-opencode2.js +58 -56
  57. package/dist/plugin.d.ts +0 -10
  58. package/dist/plugin.js +207 -127
  59. package/dist/pricing-data.d.ts +14 -1
  60. package/dist/pricing-data.js +14 -1
  61. package/dist/protocol/ask-question.d.ts +12 -1
  62. package/dist/protocol/ask-question.js +77 -3
  63. package/dist/protocol/blob-store.js +3 -2
  64. package/dist/protocol/conversation-bind.js +1 -1
  65. package/dist/protocol/conversation-persistence.d.ts +2 -0
  66. package/dist/protocol/conversation-persistence.js +11 -1
  67. package/dist/protocol/conversation-state.d.ts +2 -0
  68. package/dist/protocol/conversation-state.js +19 -0
  69. package/dist/protocol/create-plan.d.ts +47 -25
  70. package/dist/protocol/create-plan.js +35 -24
  71. package/dist/protocol/interactions.d.ts +26 -17
  72. package/dist/protocol/interactions.js +80 -31
  73. package/dist/protocol/messages.js +96 -18
  74. package/dist/protocol/request.d.ts +16 -9
  75. package/dist/protocol/request.js +17 -25
  76. package/dist/protocol/switch-mode.d.ts +66 -27
  77. package/dist/protocol/switch-mode.js +147 -73
  78. package/dist/protocol/token-details.d.ts +2 -0
  79. package/dist/protocol/token-details.js +11 -1
  80. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  81. package/dist/protocol/tool-call-bridge.js +14 -0
  82. package/dist/protocol/tools.d.ts +33 -11
  83. package/dist/protocol/tools.js +165 -103
  84. package/dist/protocol/turn-provenance.d.ts +52 -0
  85. package/dist/protocol/turn-provenance.js +174 -0
  86. package/dist/replay-safety.d.ts +6 -0
  87. package/dist/replay-safety.js +56 -6
  88. package/dist/session.d.ts +44 -2
  89. package/dist/session.js +45 -1
  90. package/dist/shared.d.ts +7 -0
  91. package/dist/shared.js +7 -0
  92. package/dist/todo-store.d.ts +2 -0
  93. package/dist/todo-store.js +19 -0
  94. package/dist/transport/connect.d.ts +2 -0
  95. package/dist/transport/connect.js +23 -6
  96. package/dist/usage.js +23 -6
  97. package/package.json +3 -2
  98. package/dist/context/agents.d.ts +0 -12
  99. package/dist/context/agents.js +0 -136
  100. package/dist/plan-execution-kickoff.d.ts +0 -57
  101. package/dist/plan-execution-kickoff.js +0 -186
@@ -1,22 +1,23 @@
1
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
- import { opencodeGlobalCacheDir } from "./context/paths.js";
3
+ import { opencode2PlanDir, opencodeGlobalCacheDir, setNativePlansDir } 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
7
  import { fetchOpenCodeWebSearchText, parseExaWebSearchResults, } from "./web-tools.js";
8
8
  import { captureCursorShellResult, cursorShellEnvForCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
9
9
  import { applyCursorProviderInventory, CURSOR_INTEGRATION_ID } from "./opencode2/catalog.js";
10
- import { applyCursorIntegration, resolveCursorAccessToken } from "./opencode2/integration.js";
10
+ import { applyCursorIntegration, requireCursorAccessToken, resolveCursorAccessToken, } from "./opencode2/integration.js";
11
11
  import { exposeDirectMcpTools, rememberDirectMcpNamespaces } from "./opencode2/mcp-direct.js";
12
12
  import { registerTodoTools } from "./opencode2/todo-tools.js";
13
+ import { registerCursorImageSaveTool } from "./opencode2/image-save-tool.js";
13
14
  import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
14
15
  import { clearSessionTodos } from "./todo-store.js";
15
16
  import { markCompactionSession } from "./compaction-marker.js";
16
17
  import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
18
+ import { rememberHostSkillFiles } from "./context/skills.js";
17
19
  import { trace } from "./debug.js";
18
- import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
19
- import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
20
+ import { cancelHostAgentModeSwitch, hostAgentSwitchPromptText, setHostAgentModeSwitch, } from "./host-agent-mode.js";
20
21
  import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
21
22
  import { CursorPlugin } from "./plugin.js";
22
23
  /**
@@ -92,45 +93,36 @@ const plugin = {
92
93
  setup: async (ctx) => {
93
94
  const cacheDir = opencodeGlobalCacheDir();
94
95
  const workspaceRoot = ctx.location?.directory || process.cwd();
96
+ // CreatePlan writes where OpenCode 2.0's Plan agent keeps plan files.
97
+ // OpenCode 2.0 sets a plugin up once per location instance and again on a
98
+ // plugin reload, then disposes older setups: each cleanup removes only
99
+ // what its own setup installed.
100
+ const disposeNativePlansDir = setNativePlansDir(opencode2PlanDir());
101
+ let disposeHostAgentModeSwitch;
102
+ trace(`opencode2 plugin: setup directory=${workspaceRoot}`);
95
103
  const hasShellEnvHook = typeof ctx.shell?.hook === "function";
96
- const admitPlanKickoff = typeof ctx.session.synthetic === "function"
97
- ? ctx.session.synthetic
98
- : ctx.session.prompt;
99
- if (typeof ctx.session.switchAgent === "function" && typeof admitPlanKickoff === "function") {
104
+ if (typeof ctx.session.switchAgent === "function") {
100
105
  const switchAgent = ctx.session.switchAgent;
101
- setPlanExecutionKickoff(async ({ sessionID, planPath }) => {
102
- await switchAgent({ sessionID, agent: "build" });
103
- try {
104
- await admitPlanKickoff({
106
+ const continueTurn = typeof ctx.session.synthetic === "function"
107
+ ? ctx.session.synthetic
108
+ : typeof ctx.session.prompt === "function"
109
+ ? ctx.session.prompt
110
+ : undefined;
111
+ disposeHostAgentModeSwitch = setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
112
+ const agent = normalizeSwitchModeId(targetModeID) === "plan"
113
+ || normalizeSwitchModeId(targetModeID) === "spec"
114
+ ? "plan"
115
+ : "build";
116
+ await switchAgent({ sessionID, agent });
117
+ // switchAgent only publishes AgentSelected and leaves the session idle.
118
+ // Continue under the new agent the same way OpenCode 1.x promptAsync does.
119
+ if (continueTurn) {
120
+ await continueTurn({
105
121
  sessionID,
106
- text: createPlanExecutionKickoffText(planPath),
122
+ text: hostAgentSwitchPromptText(agent),
107
123
  });
108
124
  }
109
- catch (error) {
110
- // Switching and admitting input are separate public APIs. Restore the
111
- // plan agent if admission fails so the shared retry state is honest:
112
- // the plan remains active rather than silently leaving the session in
113
- // build mode with no execution turn.
114
- await switchAgent({ sessionID, agent: "plan" }).catch(() => { });
115
- throw error;
116
- }
117
- });
118
- }
119
- else {
120
- setPlanExecutionKickoff(undefined);
121
- }
122
- if (typeof ctx.session.switchAgent === "function") {
123
- const switchAgent = ctx.session.switchAgent;
124
- setHostAgentModeSwitch(async ({ sessionID, targetModeID }) => {
125
- const mode = normalizeSwitchModeId(targetModeID);
126
- await switchAgent({
127
- sessionID,
128
- agent: mode === "plan" || mode === "spec" ? "plan" : "build",
129
- });
130
- });
131
- }
132
- else {
133
- setHostAgentModeSwitch(undefined);
125
+ }, continueTurn ? { resumesTurn: true } : {});
134
126
  }
135
127
  const registrations = [];
136
128
  const track = async (p) => {
@@ -140,26 +132,20 @@ const plugin = {
140
132
  let sourceConnection;
141
133
  // ── Credentials ─────────────────────────────────────────
142
134
  await track(ctx.integration.transform(applyCursorIntegration));
143
- let cachedToken;
144
135
  let tokenInflight;
145
136
  /**
146
- * Cache only a *successful* resolution.
137
+ * Current token for model discovery and endpoint warmup, or undefined.
147
138
  *
148
- * On a fresh install `setup()` runs before the user has connected, so the
149
- * first attempt necessarily returns nothing. Memoizing that would pin the
150
- * plugin to "no credentials" for the whole process and models would never
151
- * load, even after a successful /connect.
139
+ * Resolved every time (concurrent callers share one attempt) and never
140
+ * memoized: on a fresh install `setup()` runs before the user has
141
+ * connected, and a long-running daemon outlives any single token. Both
142
+ * the host resolve and the renewal layer are cheap until a renewal is due.
152
143
  */
153
144
  const accessToken = async () => {
154
- if (cachedToken)
155
- return cachedToken;
156
145
  tokenInflight ??= resolveCursorAccessToken(ctx.integration).finally(() => {
157
146
  tokenInflight = undefined;
158
147
  });
159
- const token = await tokenInflight;
160
- if (token)
161
- cachedToken = token;
162
- return token;
148
+ return tokenInflight;
163
149
  };
164
150
  const refreshSourceConnection = async () => {
165
151
  try {
@@ -210,10 +196,11 @@ const plugin = {
210
196
  return;
211
197
  if (!isCursorPackage(event.package, event.model.providerID))
212
198
  return;
213
- const token = await accessToken();
214
199
  event.sdk = createSdk({
215
200
  name: event.model.providerID || CURSOR_PROVIDER_ID,
216
- ...(token ? { accessToken: token } : {}),
201
+ // Resolved per Run open: this SDK lives as long as the daemon, so
202
+ // a token captured here would outlive its expiry.
203
+ getAccessToken: (request) => requireCursorAccessToken(ctx.integration, request),
217
204
  // Static fallback only. This hook fires once per model/package, not
218
205
  // per session, and 2.0 runs one daemon across many projects — the
219
206
  // real per-request directory comes from `x-opencode-directory`
@@ -267,6 +254,7 @@ const plugin = {
267
254
  // them as direct catalog tools (`codemode: false` + output schema) if
268
255
  // the host does not already own those names. When off, register none.
269
256
  registerTodoTools(draft);
257
+ registerCursorImageSaveTool(draft, ctx);
270
258
  // MCP tools default into Code Mode. Move every server that did not
271
259
  // explicitly opt in onto the direct catalog so this provider can
272
260
  // advertise them to Cursor (issue #29 still routes via CallDynamicTool).
@@ -380,6 +368,20 @@ const plugin = {
380
368
  // last known session directory ahead of this Location's static root.
381
369
  // Scoped by the host to this provider: other providers' requests never
382
370
  // reach the callback, so the header cannot leak to their endpoints.
371
+ // Skill files for RequestContext path-desc `agent_skills` (see context/skills.ts).
372
+ const rememberSkillFiles = async () => {
373
+ if (!ctx.skill)
374
+ return;
375
+ try {
376
+ const listed = await ctx.skill.list();
377
+ // skill.list is bound to this plugin Location. A session may have
378
+ // moved; never relabel this catalog with the session's new directory.
379
+ rememberHostSkillFiles(listed.location.directory, listed.data);
380
+ }
381
+ catch (error) {
382
+ trace(`model.request: skill.list failed: ${String(error)}`);
383
+ }
384
+ };
383
385
  await track(ctx.session.hook("model.request", async (event) => {
384
386
  const current = await ctx.session
385
387
  .get({ sessionID: event.sessionID })
@@ -392,6 +394,7 @@ const plugin = {
392
394
  const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
393
395
  if (!directory)
394
396
  return;
397
+ await rememberSkillFiles();
395
398
  event.headers = {
396
399
  ...event.headers,
397
400
  "x-opencode-directory": encodeURIComponent(directory),
@@ -467,7 +470,6 @@ const plugin = {
467
470
  retry.unref?.();
468
471
  void ensureModels().catch(() => { });
469
472
  const onCredentialSwitch = () => {
470
- cachedToken = undefined;
471
473
  modelsLoaded = false;
472
474
  credentialGeneration++;
473
475
  };
@@ -475,8 +477,9 @@ const plugin = {
475
477
  return async () => {
476
478
  clearInterval(retry);
477
479
  unsubscribe?.();
478
- setPlanExecutionKickoff(undefined);
479
- setHostAgentModeSwitch(undefined);
480
+ disposeHostAgentModeSwitch?.();
481
+ disposeNativePlansDir();
482
+ trace(`opencode2 plugin: cleanup directory=${workspaceRoot}`);
480
483
  for (const registration of registrations.reverse()) {
481
484
  await registration.dispose().catch(() => { });
482
485
  }
@@ -535,7 +538,6 @@ function applySessionActivity(event, onCredentialSwitch) {
535
538
  sessionActivity.removeSession(id);
536
539
  clearSessionTodos(id);
537
540
  clearActiveCursorMode(id);
538
- cancelPlanExecutionKickoff(id);
539
541
  cancelHostAgentModeSwitch(id);
540
542
  }
541
543
  break;
package/dist/plugin.d.ts CHANGED
@@ -1,12 +1,2 @@
1
1
  import type { Hooks, PluginInput } from "@opencode-ai/plugin";
2
- export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
3
- export declare function loadClassicTools(options?: {
4
- importModule?: (specifier: string) => Promise<{
5
- tool?: any;
6
- }>;
7
- configDirs?: string[];
8
- }): Promise<{
9
- webSearch: any;
10
- imageSave: any;
11
- }>;
12
2
  export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
package/dist/plugin.js CHANGED
@@ -1,88 +1,105 @@
1
- import path from "node:path";
2
- import { pathToFileURL } from "node:url";
3
1
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, } from "./shared.js";
4
2
  import { cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled } from "./plugin-core.js";
5
- import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
+ import { pollForTokens, exchangeApiKey, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs, isExchangeableApiKey } from "./auth.js";
4
+ import { renewSessionIfDue, resolveApiKeyToken, } from "./auth-renewal.js";
5
+ import { CursorAuthError } from "./errors.js";
6
+ import { errorMessage, trace } from "./debug.js";
6
7
  import { readCache, discoverModels, isCacheFresh } from "./models.js";
7
- // Re-exported for API compatibility: tests and downstream code import these
8
- // from "./plugin.js". Canonical home is ./model-config.ts, which stays free of
9
- // host plugin imports so the OpenCode 2.0 entrypoint can share it.
10
8
  import { modelsToConfig } from "./model-config.js";
11
- export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
12
- import { opencodeGlobalCacheDir, opencodeGlobalConfigDirs } from "./context/paths.js";
9
+ import { loadClassicTools } from "./classic-tools.js";
10
+ import { opencodeGlobalCacheDir } from "./context/paths.js";
13
11
  import { readStoredAuth } from "./context/auth-store.js";
14
12
  import { resolveAgentUrl } from "./agent-url.js";
15
13
  import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
16
14
  import { sessionActivity } from "./activity.js";
17
- import { createOpenCodeWebSearchTool, openCodeWebSearchTool } from "./web-search-tool.js";
18
- import { createCursorImageSaveTool, cursorImageSaveTool } from "./image-save-tool.js";
19
- import { createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
20
15
  import { dispatchHostEventBridge } from "./host-event-bridge.js";
16
+ import { createPromptHostAgentModeSwitch, setHostAgentModeSwitch } from "./host-agent-mode.js";
17
+ import { isHostPlanFileResolved, resolveHostPlanFile } from "./host-plan-file.js";
21
18
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
22
- export async function loadClassicTools(options = {}) {
23
- const configDir = options.configDirs?.[0] ?? opencodeGlobalConfigDirs()[0];
24
- const candidates = [
25
- ...(configDir
26
- ? [path.join(configDir, "node_modules", "@opencode-ai", "plugin", "dist", "index.js")]
27
- : []),
28
- "@opencode-ai/plugin",
29
- ];
30
- const importModule = options.importModule ?? ((specifier) => import(specifier));
31
- // The global config copy is the host-owned installation; bare import is a fallback.
32
- for (const candidate of candidates) {
33
- try {
34
- const specifier = path.win32.isAbsolute(candidate) && !path.isAbsolute(candidate)
35
- ? new URL(`file:///${candidate.replaceAll("\\", "/")}`).href
36
- : path.isAbsolute(candidate)
37
- ? pathToFileURL(candidate).href
38
- : candidate;
39
- const module = await importModule(specifier);
40
- if (typeof module.tool === "function" && module.tool.schema) {
41
- const factory = { tool: module.tool, schema: module.tool.schema };
42
- return {
43
- webSearch: createOpenCodeWebSearchTool(factory),
44
- imageSave: createCursorImageSaveTool(factory),
45
- };
46
- }
47
- }
48
- catch {
49
- // Try the next host-owned/normal resolution location.
50
- }
19
+ /**
20
+ * Raw `crsr_` key behind an API-key login: under `metadata.apiKey` (saved at
21
+ * login, and merged there from the prompt inputs by OpenCode's CLI), or as
22
+ * `key` itself when OpenCode stored the typed key without our exchange.
23
+ */
24
+ function storedApiKey(auth) {
25
+ const fromMetadata = auth.metadata?.apiKey;
26
+ if (typeof fromMetadata === "string" && isExchangeableApiKey(fromMetadata))
27
+ return fromMetadata;
28
+ return isExchangeableApiKey(auth.key) ? auth.key : undefined;
29
+ }
30
+ function sessionTokens(auth) {
31
+ // Cursor's IDE refreshes with the stored refresh token and then stores the
32
+ // new access token as both; an empty refresh field means the same.
33
+ return { accessToken: auth.access, refreshToken: auth.refresh || auth.access };
34
+ }
35
+ /** Whether `latest` is still the credential a renewal started from. */
36
+ function isSameCredential(latest, started) {
37
+ if (latest.type === "oauth" && started.type === "oauth") {
38
+ return latest.access === started.access && latest.refresh === started.refresh;
39
+ }
40
+ if (latest.type === "api" && started.type === "api") {
41
+ return latest.key === started.key && storedApiKey(latest) === storedApiKey(started);
42
+ }
43
+ return false;
44
+ }
45
+ /** Names of the host's own user-selectable primary agents, from `app.agents()`. */
46
+ function primaryAgentNames(response) {
47
+ const list = Array.isArray(response)
48
+ ? response
49
+ : Array.isArray(response?.data)
50
+ ? response.data
51
+ : undefined;
52
+ if (!list)
53
+ return undefined;
54
+ const names = new Set();
55
+ for (const item of list) {
56
+ if (typeof item?.name !== "string" || item.mode === "subagent" || item.hidden === true)
57
+ continue;
58
+ names.add(item.name);
51
59
  }
52
- // `tool()` is an identity helper; the fallback definitions use plain JSON
53
- // Schema accepted by OpenCode's legacy registry when no Zod helper is present.
54
- return { webSearch: openCodeWebSearchTool, imageSave: cursorImageSaveTool };
60
+ return names;
61
+ }
62
+ /**
63
+ * Install the OpenCode 1.x host-agent switch. Returns the agent-list refresh,
64
+ * which the caller runs once the host serves requests: the list is read from
65
+ * the host's own API, and a failed read leaves SwitchMode provider-owned.
66
+ */
67
+ function installPromptHostAgentModeSwitch(input, sessionClient, promptAsync) {
68
+ const app = input.client?.app;
69
+ const agents = app?.agents;
70
+ // Nothing to install; a switch another entrypoint installed stays in place.
71
+ if (typeof promptAsync !== "function" || typeof agents !== "function")
72
+ return undefined;
73
+ let primary;
74
+ const refresh = () => agents.call(app)
75
+ .then((response) => { primary = primaryAgentNames(response) ?? primary; })
76
+ .catch((error) => trace(`host-agent-mode: agent list unavailable: ${errorMessage(error)}`));
77
+ const { apply, accepts } = createPromptHostAgentModeSwitch(async ({ sessionID, agent, text }) => {
78
+ await promptAsync.call(sessionClient, {
79
+ path: { id: sessionID },
80
+ body: { agent, parts: [{ type: "text", text, synthetic: true }] },
81
+ });
82
+ void refresh();
83
+ }, () => primary);
84
+ setHostAgentModeSwitch(apply, { resumesTurn: true, accepts });
85
+ return refresh;
55
86
  }
56
87
  export async function CursorPlugin(input) {
57
88
  const cacheDir = opencodeGlobalCacheDir();
58
89
  const apiBaseURL = cursorApiBaseURL();
59
90
  const classicTools = await loadClassicTools();
60
- // Install the OpenCode plan-exit-shaped kickoff only when the OpenCode client
61
- // exposes that structural API. Without it, no synthetic kickoff is registered.
62
91
  const sessionClient = input.client?.session;
63
92
  const promptAsync = sessionClient?.promptAsync;
64
- setPlanExecutionKickoff(typeof promptAsync === "function"
65
- ? async ({ sessionID, planPath }) => {
66
- await promptAsync.call(sessionClient, {
67
- path: { id: sessionID },
68
- body: {
69
- agent: "build",
70
- parts: [{
71
- type: "text",
72
- text: createPlanExecutionKickoffText(planPath),
73
- synthetic: true,
74
- }],
75
- },
76
- });
77
- }
78
- : undefined);
79
- // Last access token successfully resolved in this plugin instance. Config's
80
- // loadModels can only read OpenCode's durable store (auth.json /
81
- // OPENCODE_AUTH_CONTENT); auth.loader gets live credentials via getAuth().
82
- // Those usually match, but after a refresh where persistAuthBestEffort fails,
83
- // getAuth() may still see the old credentials while we already hold a usable
84
- // token here — keep it so the loader can still discover models.
85
- let sessionAccessToken;
93
+ // Cursor SwitchMode without an advertised plan_enter selects the host's
94
+ // primary agent the OpenCode 1.x way once its Run has ended. The OpenCode 2.0
95
+ // entrypoint replaces this with `session.switchAgent` in its own setup().
96
+ const refreshHostAgents = installPromptHostAgentModeSwitch(input, sessionClient, promptAsync);
97
+ let hostAgentsLoaded;
98
+ const sessionGet = sessionClient?.get;
99
+ const getSession = typeof sessionGet === "function"
100
+ ? (sessionID) => sessionGet.call(sessionClient, { path: { id: sessionID } })
101
+ : undefined;
102
+ let lastPersistAttempt;
86
103
  async function persistAuth(body) {
87
104
  await input.client.auth.set({
88
105
  path: { id: CURSOR_PROVIDER_ID },
@@ -110,64 +127,93 @@ export async function CursorPlugin(input) {
110
127
  * and config share the same underlying credentials when possible.
111
128
  */
112
129
  async function authForLoader(getAuth) {
113
- return (await getAuth()) ?? (await authFromStore());
130
+ // getAuth is also called long after the loader returned (per-Run token
131
+ // resolution); never let a host-side failure there hide the durable store.
132
+ return (await getAuth().catch(() => undefined)) ?? (await authFromStore());
114
133
  }
115
- async function resolveAccessToken(auth) {
116
- if (auth.type === "api") {
117
- let accessToken = auth.key;
118
- const refreshToken = auth.metadata?.refreshToken;
119
- // API-key exchange returns a short-lived JWT stored as `key`. Refresh
120
- // it the same way as OAuth when it is expiring / already expired.
121
- if (refreshToken && isExpiringSoon(auth.key)) {
122
- try {
123
- const newTokens = await refreshAccessToken(refreshToken, apiBaseURL);
124
- accessToken = newTokens.accessToken;
125
- await persistAuthBestEffort({
126
- type: "api",
127
- key: newTokens.accessToken,
128
- metadata: {
129
- ...auth.metadata,
130
- refreshToken: newTokens.refreshToken,
131
- },
132
- });
133
- }
134
- catch {
135
- // refresh failed — keep the existing key; the next call may still work
136
- }
137
- }
138
- if (accessToken)
139
- sessionAccessToken = accessToken;
140
- return accessToken;
134
+ /**
135
+ * Persist a renewed credential unless the stored one changed meanwhile (a
136
+ * re-login, or another process's renewal): never overwrite newer state.
137
+ */
138
+ async function persistRenewal(started, next, readCurrent) {
139
+ // One attempt per renewed token: when the write fails (read-only store,
140
+ // injected OPENCODE_AUTH_CONTENT) later Runs keep the in-memory token
141
+ // instead of re-reading and re-writing the store on every turn.
142
+ const token = next.type === "oauth" ? next.access : next.type === "api" ? next.key : undefined;
143
+ if (token === undefined || token === lastPersistAttempt)
144
+ return;
145
+ lastPersistAttempt = token;
146
+ const latest = await readCurrent().catch(() => undefined);
147
+ if (!latest || !isSameCredential(latest, started)) {
148
+ trace("auth: stored Cursor credential changed during renewal; keeping the stored one");
149
+ return;
141
150
  }
142
- if (auth.type === "oauth") {
143
- if (!isExpiringSoon(auth.access)) {
144
- sessionAccessToken = auth.access;
145
- return auth.access;
146
- }
147
- if (!auth.refresh)
148
- return undefined;
149
- try {
150
- const newTokens = await refreshAccessToken(auth.refresh, apiBaseURL);
151
- // Preserve optional OAuth fields (v2 Auth / plugin may carry these).
152
- const extras = auth;
153
- // Use the new token even if persisting back to OpenCode fails.
154
- await persistAuthBestEffort({
155
- type: "oauth",
156
- access: newTokens.accessToken,
157
- refresh: newTokens.refreshToken,
158
- expires: decodeJwtExpiryMs(newTokens.accessToken) ?? Date.now(),
159
- ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
160
- ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
161
- });
162
- sessionAccessToken = newTokens.accessToken;
163
- return newTokens.accessToken;
164
- }
165
- catch {
166
- return undefined;
167
- }
151
+ await persistAuthBestEffort(next);
152
+ }
153
+ /** Browser-login session: renew when due (or forced) and persist the result. */
154
+ async function resolveSession(auth, readCurrent, force = false) {
155
+ const renewal = await renewSessionIfDue(sessionTokens(auth), { baseUrl: apiBaseURL, force });
156
+ if (renewal.renewed) {
157
+ // Preserve optional OAuth fields (v2 Auth / plugin may carry these).
158
+ const extras = auth;
159
+ await persistRenewal(auth, {
160
+ type: "oauth",
161
+ access: renewal.accessToken,
162
+ refresh: renewal.accessToken,
163
+ expires: decodeJwtExpiryMs(renewal.accessToken) ?? Date.now(),
164
+ ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
165
+ ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
166
+ }, readCurrent);
167
+ }
168
+ return renewal;
169
+ }
170
+ /** API-key login: re-exchange the stored raw key when the JWT nears expiry. */
171
+ async function resolveApiKeyLogin(auth, readCurrent, force = false) {
172
+ const apiKey = storedApiKey(auth);
173
+ if (!apiKey) {
174
+ // Saved by an older version that kept only the exchanged JWT and its
175
+ // refresh token. Cursor renews API-key logins only by exchanging the key.
176
+ if (!isExpiringSoon(auth.key, 30))
177
+ return auth.key;
178
+ throw new CursorAuthError("This Cursor API-key login was saved without the key, so it cannot be renewed; sign in again with the API key", { code: "api_key_missing" });
168
179
  }
180
+ const token = await resolveApiKeyToken(apiKey, {
181
+ baseUrl: apiBaseURL,
182
+ ...(isExchangeableApiKey(auth.key) ? {} : { seed: auth.key }),
183
+ force,
184
+ });
185
+ if (token.renewed) {
186
+ const { refreshToken: _unused, ...metadata } = auth.metadata ?? {};
187
+ await persistRenewal(auth, {
188
+ type: "api",
189
+ key: token.accessToken,
190
+ metadata: { ...metadata, apiKey },
191
+ }, readCurrent);
192
+ }
193
+ return token.accessToken;
194
+ }
195
+ /**
196
+ * Current access token for the stored credential. The two credential kinds
197
+ * are handled by separate paths and never substitute for each other.
198
+ */
199
+ async function resolveAccessToken(auth, readCurrent, force = false) {
200
+ if (auth.type === "oauth")
201
+ return (await resolveSession(auth, readCurrent, force)).accessToken;
202
+ if (auth.type === "api")
203
+ return resolveApiKeyLogin(auth, readCurrent, force);
169
204
  return undefined;
170
205
  }
206
+ /** Best-effort token for startup work (model discovery, endpoint warmup). */
207
+ async function startupAccessToken(auth, readCurrent) {
208
+ try {
209
+ return await resolveAccessToken(auth, readCurrent);
210
+ }
211
+ catch (error) {
212
+ // Surfaced again, with the same message, when a Run asks for a token.
213
+ trace(`auth: no usable Cursor token at startup (${errorMessage(error)})`);
214
+ return undefined;
215
+ }
216
+ }
171
217
  async function loadModels() {
172
218
  const cached = await readCache(cacheDir);
173
219
  if (cached?.models.length && isCacheFresh(cached)) {
@@ -178,7 +224,7 @@ export async function CursorPlugin(input) {
178
224
  // or old-schema caches here so this process materializes the new model set.
179
225
  const auth = await authFromStore();
180
226
  if (auth) {
181
- const accessToken = await resolveAccessToken(auth);
227
+ const accessToken = await startupAccessToken(auth, authFromStore);
182
228
  if (accessToken) {
183
229
  try {
184
230
  const models = await discoverModels(accessToken, cacheDir, { baseURL: apiBaseURL });
@@ -269,6 +315,22 @@ export async function CursorPlugin(input) {
269
315
  // Carry the canonical OpenCode id so an incompatible Cursor checkpoint
270
316
  // is rotated instead of resuming the prior agent's prompt.
271
317
  output.options[CURSOR_HOST_AGENT_OPTION] = hookInput.agent;
318
+ // First Cursor request: the host now serves its API, so read its agents
319
+ // before any Run can raise SwitchMode. Concurrent first requests share
320
+ // the one read; later requests find it settled.
321
+ hostAgentsLoaded ??= refreshHostAgents?.();
322
+ await hostAgentsLoaded;
323
+ // Know the session's own plan file (OpenCode's `Session.plan`) before the
324
+ // Run starts, so CreatePlan records the plan where the plan agent and
325
+ // plan_exit read it. It is fixed per session, so resolve it once.
326
+ if (hookInput.agent !== "compaction" && getSession && !isHostPlanFileResolved(hookInput.sessionID)) {
327
+ await resolveHostPlanFile({
328
+ sessionID: hookInput.sessionID,
329
+ getSession,
330
+ worktree: input.worktree || input.project?.worktree,
331
+ vcs: !!input.project?.vcs,
332
+ });
333
+ }
272
334
  // OpenCode's compaction pipeline invokes the LLM with agent="compaction".
273
335
  // Carry that stable runtime fact into LanguageModelV3 providerOptions so
274
336
  // the provider never has to guess from an empty tool list.
@@ -352,7 +414,9 @@ export async function CursorPlugin(input) {
352
414
  type: "success",
353
415
  key: result.accessToken,
354
416
  provider: CURSOR_PROVIDER_ID,
355
- metadata: { refreshToken: result.refreshToken },
417
+ // Keep the raw key: Cursor renews an API-key login only by
418
+ // exchanging the key again (its refresh token is never used).
419
+ metadata: { apiKey },
356
420
  };
357
421
  }
358
422
  catch {
@@ -362,10 +426,11 @@ export async function CursorPlugin(input) {
362
426
  },
363
427
  ],
364
428
  async loader(getAuth) {
365
- const auth = await authForLoader(getAuth);
366
- // Prefer credentials from getAuth/store; if refresh already succeeded in
367
- // loadModels but persist failed, fall back to the in-memory session token.
368
- const accessToken = (auth ? await resolveAccessToken(auth) : undefined) ?? sessionAccessToken;
429
+ const readCurrent = () => authForLoader(getAuth);
430
+ const auth = await readCurrent();
431
+ // Model discovery and endpoint warmup need a token: like any request,
432
+ // that renews a due session (or expiring API-key JWT) first.
433
+ const accessToken = auth ? await startupAccessToken(auth, readCurrent) : undefined;
369
434
  if (accessToken) {
370
435
  // Skip when config already filled a fresh cache (avoids a second
371
436
  // AvailableModels round-trip + background refresh on cold start).
@@ -384,8 +449,23 @@ export async function CursorPlugin(input) {
384
449
  telemetryEnabled: cursorGetServerConfigTelemetryEnabled(),
385
450
  }).catch(() => { });
386
451
  }
452
+ // Asked for on every Run open: reads the live credential (so a
453
+ // re-login applies without a restart), renews it when due, persists the
454
+ // renewal. This is how OpenCode's own OAuth providers hand over
455
+ // credentials (codex, xai, copilot: a `fetch` that calls getAuth() per
456
+ // request): no token is placed in the options, which OpenCode serves
457
+ // unredacted from /provider; a function is dropped there.
458
+ const getAccessToken = async (request) => {
459
+ const current = await readCurrent();
460
+ const token = current
461
+ ? await resolveAccessToken(current, readCurrent, request?.forceRefresh === true)
462
+ : undefined;
463
+ if (!token)
464
+ throw new CursorAuthError("No Cursor login found; sign in to Cursor", { code: "no_credential" });
465
+ return token;
466
+ };
387
467
  return {
388
- ...(accessToken ? { accessToken } : {}),
468
+ getAccessToken,
389
469
  workspaceRoot: input.directory,
390
470
  cacheDir,
391
471
  };