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.
- package/CHANGELOG.md +79 -0
- package/README.md +36 -15
- package/SECURITY.md +43 -7
- package/dist/agent-url.js +2 -2
- package/dist/auth-renewal.d.ts +85 -0
- package/dist/auth-renewal.js +284 -0
- package/dist/auth.d.ts +38 -22
- package/dist/auth.js +79 -96
- package/dist/classic-tools.d.ts +15 -0
- package/dist/classic-tools.js +45 -0
- package/dist/context/build.d.ts +62 -7
- package/dist/context/build.js +127 -95
- package/dist/context/dynamic-catalog.d.ts +2 -58
- package/dist/context/dynamic-catalog.js +16 -161
- package/dist/context/epoch.d.ts +6 -4
- package/dist/context/epoch.js +22 -10
- package/dist/context/frozen.js +42 -15
- package/dist/context/index.d.ts +3 -1
- package/dist/context/index.js +3 -1
- package/dist/context/overlay.d.ts +2 -23
- package/dist/context/overlay.js +6 -30
- package/dist/context/paths.d.ts +51 -21
- package/dist/context/paths.js +78 -27
- package/dist/context/rules.d.ts +3 -15
- package/dist/context/rules.js +10 -186
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +31 -8
- package/dist/context/skills.js +187 -86
- package/dist/debug.d.ts +2 -0
- package/dist/debug.js +4 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +27 -3
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +119 -15
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +119 -28
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +35 -23
- package/dist/index.d.ts +10 -0
- package/dist/language-model.d.ts +86 -25
- package/dist/language-model.js +1295 -369
- package/dist/model-config.d.ts +9 -0
- package/dist/model-config.js +28 -1
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/integration.d.ts +30 -7
- package/dist/opencode2/integration.js +75 -46
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +58 -56
- package/dist/plugin.d.ts +0 -10
- package/dist/plugin.js +207 -127
- package/dist/pricing-data.d.ts +14 -1
- package/dist/pricing-data.js +14 -1
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/blob-store.js +3 -2
- package/dist/protocol/conversation-bind.js +1 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +11 -1
- package/dist/protocol/conversation-state.d.ts +2 -0
- package/dist/protocol/conversation-state.js +19 -0
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +35 -24
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +96 -18
- package/dist/protocol/request.d.ts +16 -9
- package/dist/protocol/request.js +17 -25
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +4 -0
- package/dist/protocol/tool-call-bridge.js +14 -0
- package/dist/protocol/tools.d.ts +33 -11
- package/dist/protocol/tools.js +165 -103
- package/dist/protocol/turn-provenance.d.ts +52 -0
- package/dist/protocol/turn-provenance.js +174 -0
- package/dist/replay-safety.d.ts +6 -0
- package/dist/replay-safety.js +56 -6
- package/dist/session.d.ts +44 -2
- package/dist/session.js +45 -1
- package/dist/shared.d.ts +7 -0
- package/dist/shared.js +7 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +2 -0
- package/dist/transport/connect.js +23 -6
- package/dist/usage.js +23 -6
- package/package.json +3 -2
- package/dist/context/agents.d.ts +0 -12
- package/dist/context/agents.js +0 -136
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
package/dist/plugin-opencode2.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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:
|
|
122
|
+
text: hostAgentSwitchPromptText(agent),
|
|
107
123
|
});
|
|
108
124
|
}
|
|
109
|
-
|
|
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
|
-
*
|
|
137
|
+
* Current token for model discovery and endpoint warmup, or undefined.
|
|
147
138
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
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,
|
|
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
|
-
|
|
12
|
-
import { opencodeGlobalCacheDir
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
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
|
-
|
|
468
|
+
getAccessToken,
|
|
389
469
|
workspaceRoot: input.directory,
|
|
390
470
|
cacheDir,
|
|
391
471
|
};
|