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
@@ -8,6 +8,15 @@ import { type ModelInfo, type ModelVariant } from "./models.js";
8
8
  * tagged redundantly.
9
9
  */
10
10
  export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
11
+ /**
12
+ * models.dev-style family for a Cursor model id: the id without its version
13
+ * segments and context/speed suffixes (`claude-haiku-4-5` → `claude-haiku`,
14
+ * `gemini-3.8-flash` → `gemini-flash`, `gpt-5.6-luna` → `gpt-luna`).
15
+ * Kimi keeps its major generation (`kimi-k2.7-code` → `kimi-k2`). OpenCode
16
+ * picks its small title model by family and falls back to the session model when
17
+ * no entry has one. Cursor Auto (`default`) is not a model family.
18
+ */
19
+ export declare function cursorModelFamily(id: string): string | undefined;
11
20
  export declare function modelInfoToConfig(mi: ModelInfo, options?: {
12
21
  thinkingSuffix?: boolean;
13
22
  contextTier?: "base" | "long";
@@ -91,7 +91,7 @@ function modelInfoVariants(mi, variants) {
91
91
  // variant entry indistinguishable from the model entry in pickers that
92
92
  // collapse them.
93
93
  if (key === baseName && !usedKeys.has(key)) {
94
- key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
94
+ key = `${baseName}${tagDims(v.parameterValues) || " default"}`;
95
95
  }
96
96
  else if (usedKeys.has(key)) {
97
97
  key = `${sanitized}${tagDims(v.parameterValues)}`;
@@ -140,6 +140,27 @@ export function thinkingSuffixBaseNames(models) {
140
140
  ambiguous.add(base);
141
141
  return ambiguous;
142
142
  }
143
+ /**
144
+ * models.dev-style family for a Cursor model id: the id without its version
145
+ * segments and context/speed suffixes (`claude-haiku-4-5` → `claude-haiku`,
146
+ * `gemini-3.8-flash` → `gemini-flash`, `gpt-5.6-luna` → `gpt-luna`).
147
+ * Kimi keeps its major generation (`kimi-k2.7-code` → `kimi-k2`). OpenCode
148
+ * picks its small title model by family and falls back to the session model when
149
+ * no entry has one. Cursor Auto (`default`) is not a model family.
150
+ */
151
+ export function cursorModelFamily(id) {
152
+ if (id === "default")
153
+ return undefined;
154
+ const base = id.replace(/(?:-(?:1m|fast))+$/, "");
155
+ const kimi = /^kimi-(k\d+)(?:[.p]\d+)*(?:-code(?:-highspeed)?)?$/.exec(base);
156
+ if (kimi)
157
+ return `kimi-${kimi[1]}`;
158
+ const family = base
159
+ .split("-")
160
+ .filter((part) => !/^\d+(?:[.p]\d+)*$/.test(part))
161
+ .join("-");
162
+ return family || undefined;
163
+ }
143
164
  export function modelInfoToConfig(mi, options = {}) {
144
165
  const contextTier = options.contextTier ?? "base";
145
166
  const variants = options.variants ?? variantsForTier(mi, contextTier);
@@ -182,6 +203,12 @@ export function modelInfoToConfig(mi, options = {}) {
182
203
  output,
183
204
  },
184
205
  };
206
+ // Optional cache metadata may supply a family; the live AvailableModels
207
+ // schema has no family field. Empty metadata must not block derivation.
208
+ const reported = typeof mi.family === "string" ? mi.family.trim() : "";
209
+ const family = reported || cursorModelFamily(mi.id);
210
+ if (family)
211
+ config.family = family;
185
212
  const variantConfig = modelInfoVariants(mi, variants);
186
213
  if (variantConfig)
187
214
  config.variants = variantConfig;
@@ -36,6 +36,7 @@ export type CatalogModelInfo = {
36
36
  modelID: string;
37
37
  providerID: string;
38
38
  name: string;
39
+ family?: string;
39
40
  capabilities: {
40
41
  tools: boolean;
41
42
  input: string[];
@@ -69,6 +69,8 @@ export function modelConfigEntryToInfo(id, entry) {
69
69
  time: { released: 0 },
70
70
  cost: toOpenCode2Costs(entry.cost),
71
71
  };
72
+ if (typeof entry.family === "string" && entry.family.trim())
73
+ info.family = entry.family.trim();
72
74
  if (options)
73
75
  info.settings = { ...options };
74
76
  return info;
@@ -0,0 +1,8 @@
1
+ import type { PluginContext, ToolDraft } from "./types.js";
2
+ /**
3
+ * Register the handle-only image commit tool on the OpenCode 2.0 direct
4
+ * catalog. Catalog `permission: "edit"` hides it when the host agent denies
5
+ * edits. Per-call `ask` is used when the runtime context provides it;
6
+ * otherwise containment still gates the write (opaque `image_id` only).
7
+ */
8
+ export declare function registerCursorImageSaveTool(draft: ToolDraft, ctx: PluginContext): void;
@@ -0,0 +1,84 @@
1
+ import { executeCursorImageSave } from "../image-save.js";
2
+ import { cursorImageSaveTool } from "../image-save-tool.js";
3
+ import { CURSOR_IMAGE_SAVE_TOOL } from "../protocol/generate-image.js";
4
+ import { getSessionDirectory } from "../session-directory.js";
5
+ import { hostHasTool, OPENCODE2_DIRECT_TOOL_OPTIONS } from "./todo-tools.js";
6
+ const IMAGE_SAVE_INPUT_SCHEMA = {
7
+ type: "object",
8
+ additionalProperties: false,
9
+ properties: {
10
+ image_id: {
11
+ type: "string",
12
+ description: "Id of the pending Cursor-generated image to save",
13
+ },
14
+ },
15
+ required: ["image_id"],
16
+ };
17
+ const IMAGE_SAVE_OUTPUT_SCHEMA = {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ properties: {
21
+ path: { type: "string" },
22
+ bytes: { type: "number" },
23
+ message: { type: "string" },
24
+ },
25
+ required: ["path", "bytes", "message"],
26
+ };
27
+ function toolAsk(context) {
28
+ if (!context || typeof context !== "object")
29
+ return undefined;
30
+ const ask = context.ask;
31
+ return typeof ask === "function"
32
+ ? ask
33
+ : undefined;
34
+ }
35
+ async function workspaceForSession(ctx, sessionID) {
36
+ const marked = getSessionDirectory(sessionID);
37
+ if (marked)
38
+ return marked;
39
+ try {
40
+ return (await ctx.session.get({ sessionID })).location.directory;
41
+ }
42
+ catch {
43
+ return ctx.location?.directory || process.cwd();
44
+ }
45
+ }
46
+ /**
47
+ * Register the handle-only image commit tool on the OpenCode 2.0 direct
48
+ * catalog. Catalog `permission: "edit"` hides it when the host agent denies
49
+ * edits. Per-call `ask` is used when the runtime context provides it;
50
+ * otherwise containment still gates the write (opaque `image_id` only).
51
+ */
52
+ export function registerCursorImageSaveTool(draft, ctx) {
53
+ if (hostHasTool(draft, CURSOR_IMAGE_SAVE_TOOL))
54
+ return;
55
+ draft.add({
56
+ name: CURSOR_IMAGE_SAVE_TOOL,
57
+ description: cursorImageSaveTool.description,
58
+ input: IMAGE_SAVE_INPUT_SCHEMA,
59
+ output: IMAGE_SAVE_OUTPUT_SCHEMA,
60
+ options: { ...OPENCODE2_DIRECT_TOOL_OPTIONS, permission: "edit" },
61
+ execute: async (input, context) => {
62
+ const sessionID = context.sessionID;
63
+ if (typeof sessionID !== "string" || !sessionID) {
64
+ throw new Error("OpenCode 2.0 image-save context did not provide a sessionID");
65
+ }
66
+ const directory = await workspaceForSession(ctx, sessionID);
67
+ const result = await executeCursorImageSave({ image_id: input?.image_id }, {
68
+ worktree: directory,
69
+ directory,
70
+ // The public 2.0 ToolContext has no permission prompt; catalog
71
+ // `permission: "edit"` and containment gate the write instead.
72
+ ask: toolAsk(context) ?? null,
73
+ });
74
+ return {
75
+ output: {
76
+ path: result.title,
77
+ bytes: result.bytes,
78
+ message: result.output,
79
+ },
80
+ content: result.output,
81
+ };
82
+ },
83
+ });
84
+ }
@@ -1,4 +1,5 @@
1
- import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
1
+ import { type AccessTokenRequest } from "../auth-renewal.js";
2
+ import type { CredentialOAuth, CredentialValue, IntegrationDomain, IntegrationDraft } from "./types.js";
2
3
  /**
3
4
  * Integration registration for the OpenCode 2.0 plugin — the replacement for the
4
5
  * classic plugin's `auth` hook (`methods` + `loader`).
@@ -9,15 +10,37 @@ import type { CredentialValue, IntegrationDomain, IntegrationDraft } from "./typ
9
10
  export declare const CURSOR_OAUTH_METHOD_ID = "oauth";
10
11
  /** Env vars that can supply a Cursor API key without running /connect. */
11
12
  export declare const CURSOR_ENV_NAMES: string[];
13
+ /**
14
+ * Host-driven session renewal (`POST /oauth/token`, as Cursor's IDE does).
15
+ * OpenCode 2.0 calls this when the stored `expires` is within five minutes
16
+ * (`packages/core/src/integration.ts`, `connection.resolve`) and persists the
17
+ * result. A session this process already renewed in memory is handed over
18
+ * instead of renewed again. A transient failure returns the unchanged session
19
+ * with `expires` moved to the next allowed attempt, so the host keeps working
20
+ * and asks again later; only a session Cursor ended, or one that expired
21
+ * unrenewed, throws.
22
+ */
23
+ export declare function refreshOAuth(credential: CredentialOAuth): Promise<CredentialOAuth>;
12
24
  /** Register the Cursor integration and its three connection methods. */
13
25
  export declare function applyCursorIntegration(draft: IntegrationDraft): void;
14
26
  /**
15
- * Turn a stored credential into a Cursor access token.
27
+ * Turn a stored credential into a Cursor access token. The two credential
28
+ * kinds are handled separately and never substitute for each other.
29
+ *
30
+ * - `oauth`: the session token, renewed in memory once it is due by Cursor's
31
+ * IDE window (the host itself refreshes only in the last five minutes; it
32
+ * then persists the renewal through `refreshOAuth`).
33
+ * - `key`: a raw `crsr_…` API key (from /connect or `CURSOR_API_KEY`),
34
+ * exchanged for a short-lived JWT and re-exchanged near its expiry. Any
35
+ * other value is an already-issued token and is used as-is.
16
36
  *
17
- * OAuth credentials already hold a JWT (the host refreshes them via `refresh`
18
- * above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
19
- * to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
37
+ * Throws `CursorAuthError` when no usable token can be produced.
38
+ */
39
+ export declare function accessTokenFromCredential(credential: CredentialValue | undefined, request?: AccessTokenRequest): Promise<string>;
40
+ /**
41
+ * Current access token of the active Cursor connection, for a Run. Throws a
42
+ * `CursorAuthError` that says why when there is none.
20
43
  */
21
- export declare function accessTokenFromCredential(credential: CredentialValue | undefined): Promise<string | undefined>;
22
- /** Resolve the active Cursor connection into an access token, if any. */
44
+ export declare function requireCursorAccessToken(integration: IntegrationDomain, request?: AccessTokenRequest): Promise<string>;
45
+ /** Access token of the active Cursor connection, or undefined (model discovery). */
23
46
  export declare function resolveCursorAccessToken(integration: IntegrationDomain): Promise<string | undefined>;
@@ -1,6 +1,9 @@
1
1
  import { CURSOR_WEBSITE_HOST } from "../shared.js";
2
2
  import { cursorApiBaseURL } from "../plugin-core.js";
3
- import { buildLoginUrl, decodeJwtExpiryMs, exchangeApiKey, generatePkceChallenge, generatePkceParams, isExpiringSoon, pollForTokens, refreshAccessToken, } from "../auth.js";
3
+ import { buildLoginUrl, decodeJwtExpiryMs, generatePkceChallenge, generatePkceParams, isExchangeableApiKey, pollForTokens, } from "../auth.js";
4
+ import { renewSessionIfDue, resolveApiKeyToken, } from "../auth-renewal.js";
5
+ import { CursorAuthError, CursorProviderError } from "../errors.js";
6
+ import { errorMessage } from "../debug.js";
4
7
  import { CURSOR_INTEGRATION_ID } from "./catalog.js";
5
8
  /**
6
9
  * Integration registration for the OpenCode 2.0 plugin — the replacement for the
@@ -15,6 +18,13 @@ export const CURSOR_ENV_NAMES = ["CURSOR_API_KEY"];
15
18
  function websiteURL() {
16
19
  return process.env.CURSOR_WEBSITE_URL ?? `https://${CURSOR_WEBSITE_HOST}`;
17
20
  }
21
+ /** `expires` of a session credential: the JWT's own expiry, as OpenCode's built-in OAuth integrations store it. */
22
+ function sessionExpires(token) {
23
+ return decodeJwtExpiryMs(token) ?? Date.now();
24
+ }
25
+ function sessionTokensOf(credential) {
26
+ return { accessToken: credential.access, refreshToken: credential.refresh || credential.access };
27
+ }
18
28
  /** Browser (PKCE) login: open URL, then poll until Cursor hands back tokens. */
19
29
  async function authorizeOAuth() {
20
30
  const params = generatePkceParams();
@@ -30,23 +40,38 @@ async function authorizeOAuth() {
30
40
  methodID: CURSOR_OAUTH_METHOD_ID,
31
41
  access: result.accessToken,
32
42
  refresh: result.refreshToken,
33
- expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
43
+ expires: sessionExpires(result.accessToken),
34
44
  })),
35
45
  };
36
46
  }
37
47
  /**
38
- * Renew an expiring Cursor JWT. The host calls this lazily when the stored
39
- * credential is close to expiry, so it must not assume it runs on every request.
48
+ * Host-driven session renewal (`POST /oauth/token`, as Cursor's IDE does).
49
+ * OpenCode 2.0 calls this when the stored `expires` is within five minutes
50
+ * (`packages/core/src/integration.ts`, `connection.resolve`) and persists the
51
+ * result. A session this process already renewed in memory is handed over
52
+ * instead of renewed again. A transient failure returns the unchanged session
53
+ * with `expires` moved to the next allowed attempt, so the host keeps working
54
+ * and asks again later; only a session Cursor ended, or one that expired
55
+ * unrenewed, throws.
40
56
  */
41
- async function refreshOAuth(credential) {
42
- const tokens = await refreshAccessToken(credential.refresh, cursorApiBaseURL());
57
+ export async function refreshOAuth(credential) {
58
+ const renewal = await renewSessionIfDue(sessionTokensOf(credential), { baseUrl: cursorApiBaseURL() });
59
+ const methodID = credential.methodID || CURSOR_OAUTH_METHOD_ID;
60
+ const metadata = credential.metadata === undefined ? {} : { metadata: credential.metadata };
61
+ if (renewal.renewed) {
62
+ return {
63
+ type: "oauth",
64
+ methodID,
65
+ access: renewal.accessToken,
66
+ refresh: renewal.accessToken,
67
+ expires: sessionExpires(renewal.accessToken),
68
+ ...metadata,
69
+ };
70
+ }
43
71
  return {
44
- type: "oauth",
45
- methodID: credential.methodID || CURSOR_OAUTH_METHOD_ID,
46
- access: tokens.accessToken,
47
- refresh: tokens.refreshToken,
48
- expires: decodeJwtExpiryMs(tokens.accessToken) ?? Date.now(),
49
- ...(credential.metadata === undefined ? {} : { metadata: credential.metadata }),
72
+ ...credential,
73
+ methodID,
74
+ expires: renewal.retryAt ?? sessionExpires(credential.access),
50
75
  };
51
76
  }
52
77
  /** Register the Cursor integration and its three connection methods. */
@@ -75,48 +100,52 @@ export function applyCursorIntegration(draft) {
75
100
  });
76
101
  }
77
102
  /**
78
- * Turn a stored credential into a Cursor access token.
103
+ * Turn a stored credential into a Cursor access token. The two credential
104
+ * kinds are handled separately and never substitute for each other.
79
105
  *
80
- * OAuth credentials already hold a JWT (the host refreshes them via `refresh`
81
- * above). A `key` credential is the raw `crsr_…` API key, which Cursor requires us
82
- * to exchange for a short-lived JWT — mirroring the classic plugin's behavior.
106
+ * - `oauth`: the session token, renewed in memory once it is due by Cursor's
107
+ * IDE window (the host itself refreshes only in the last five minutes; it
108
+ * then persists the renewal through `refreshOAuth`).
109
+ * - `key`: a raw `crsr_…` API key (from /connect or `CURSOR_API_KEY`),
110
+ * exchanged for a short-lived JWT and re-exchanged near its expiry. Any
111
+ * other value is an already-issued token and is used as-is.
112
+ *
113
+ * Throws `CursorAuthError` when no usable token can be produced.
83
114
  */
84
- export async function accessTokenFromCredential(credential) {
85
- if (!credential)
86
- return undefined;
87
- if (credential.type === "oauth") {
88
- if (credential.access && !isExpiringSoon(credential.access))
89
- return credential.access;
90
- if (!credential.refresh)
91
- return credential.access || undefined;
92
- try {
93
- return (await refreshOAuth(credential)).access;
94
- }
95
- catch {
96
- // Fall back to the existing token; the call may still succeed.
97
- return credential.access || undefined;
98
- }
115
+ export async function accessTokenFromCredential(credential, request = {}) {
116
+ const force = request.forceRefresh === true;
117
+ if (credential?.type === "oauth") {
118
+ return (await renewSessionIfDue(sessionTokensOf(credential), { baseUrl: cursorApiBaseURL(), force })).accessToken;
99
119
  }
100
- if (credential.type === "key") {
101
- // Already a JWT (e.g. exchanged by an earlier run) — use it directly.
102
- if (!credential.key.startsWith("crsr_"))
120
+ if (credential?.type === "key") {
121
+ if (!isExchangeableApiKey(credential.key))
103
122
  return credential.key;
104
- try {
105
- return (await exchangeApiKey(credential.key, cursorApiBaseURL())).accessToken;
106
- }
107
- catch {
108
- return undefined;
109
- }
123
+ return (await resolveApiKeyToken(credential.key, { baseUrl: cursorApiBaseURL(), force })).accessToken;
110
124
  }
111
- return undefined;
125
+ throw new CursorAuthError("No Cursor login found; connect Cursor first", { code: "no_credential" });
112
126
  }
113
- /** Resolve the active Cursor connection into an access token, if any. */
114
- export async function resolveCursorAccessToken(integration) {
127
+ /**
128
+ * Current access token of the active Cursor connection, for a Run. Throws a
129
+ * `CursorAuthError` that says why when there is none.
130
+ */
131
+ export async function requireCursorAccessToken(integration, request = {}) {
132
+ let credential;
115
133
  try {
116
134
  const connection = await integration.connection.active(CURSOR_INTEGRATION_ID);
117
- if (!connection)
118
- return undefined;
119
- return await accessTokenFromCredential(await integration.connection.resolve(connection));
135
+ credential = connection ? await integration.connection.resolve(connection) : undefined;
136
+ }
137
+ catch (error) {
138
+ // The host's resolve runs our refreshOAuth; keep its classified error.
139
+ if (error instanceof CursorProviderError)
140
+ throw error;
141
+ throw new CursorAuthError(`Cursor login could not be loaded (${errorMessage(error)})`, { cause: error });
142
+ }
143
+ return accessTokenFromCredential(credential, request);
144
+ }
145
+ /** Access token of the active Cursor connection, or undefined (model discovery). */
146
+ export async function resolveCursorAccessToken(integration) {
147
+ try {
148
+ return await requireCursorAccessToken(integration);
120
149
  }
121
150
  catch {
122
151
  return undefined;
@@ -43,6 +43,7 @@ export type ModelInfo2 = {
43
43
  modelID: string;
44
44
  providerID: string;
45
45
  name: string;
46
+ family?: string;
46
47
  capabilities: {
47
48
  tools: boolean;
48
49
  input: readonly string[];
@@ -415,6 +416,19 @@ export type McpEditor = {
415
416
  export type McpDomain = {
416
417
  readonly transform: Transform<McpEditor>;
417
418
  };
419
+ /** Only the fields this plugin reads from a registered skill. */
420
+ export type SkillInfo = {
421
+ readonly id: string;
422
+ readonly path: string;
423
+ };
424
+ export type SkillDomain = {
425
+ readonly list: () => Promise<{
426
+ readonly location: {
427
+ readonly directory: string;
428
+ };
429
+ readonly data: readonly SkillInfo[];
430
+ }>;
431
+ };
418
432
  export type PluginLocation = {
419
433
  readonly directory: string;
420
434
  };
@@ -429,6 +443,7 @@ export type PluginContext = {
429
443
  readonly shell?: ShellDomain;
430
444
  readonly websearch?: WebSearchDomain;
431
445
  readonly mcp?: McpDomain;
446
+ readonly skill?: SkillDomain;
432
447
  };
433
448
  export type Cleanup = () => Promise<void> | void;
434
449
  export type Plugin2 = {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Parallel tool-call step boundary for OpenCode's AI SDK step model.
3
+ *
4
+ * Cursor fires execs as they are generated; CLI demuxes them concurrently and
5
+ * has no step boundary. OpenCode needs one `finish: tool-calls` after every
6
+ * call of the generation. Cursor's `tool_requests_listed` (InteractionUpdate
7
+ * field 27) carries that count. See tasks/plans/parallel-tool-calls-cli-parity.md.
8
+ */
9
+ /** Idle without a progress frame while holding → close the step (ms). */
10
+ export declare const PARALLEL_STEP_IDLE_MS = 1500;
11
+ export type ParallelStepState = {
12
+ /** From field 27; undefined until Cursor lists the count for this step. */
13
+ listedCount?: number;
14
+ /** Model tool_call_ids seen this step. */
15
+ callIds: Set<string>;
16
+ /** Ids with a final client disposition this step. */
17
+ resolved: Set<string>;
18
+ /** Host tool-call parts emitted this step. */
19
+ emitted: number;
20
+ /** Wall time of the last progress frame (for the liveness guard). */
21
+ lastProgressAt: number;
22
+ };
23
+ export declare function noteToolRequestsListedSeen(): void;
24
+ export declare function hasSeenToolRequestsListed(): boolean;
25
+ /** Test-only: reset the process capability gate. */
26
+ export declare function resetToolRequestsListedSeenForTests(): void;
27
+ export declare function createParallelStep(now?: number): ParallelStepState;
28
+ export declare function noteParallelStepProgress(step: ParallelStepState, now?: number): void;
29
+ export declare function noteListedCallCount(step: ParallelStepState, callCount: number, now?: number): void;
30
+ /**
31
+ * Record a final disposition for `callId`. Returns true when newly resolved.
32
+ * Empty ids are ignored. Prior-step ids in `prior` are ignored.
33
+ */
34
+ export declare function resolveParallelStepCall(step: ParallelStepState, callId: string | undefined, prior?: ReadonlySet<string>, now?: number): boolean;
35
+ export declare function recordParallelStepEmission(step: ParallelStepState, now?: number): void;
36
+ /** True when the listed count is known and every listed call is resolved. */
37
+ export declare function parallelStepCountMet(step: ParallelStepState): boolean;
38
+ /**
39
+ * Whether to keep pumping for more calls of this step instead of finishing.
40
+ *
41
+ * - Gate off and no count yet → do not hold (today's one-call finish).
42
+ * - Human-gated interaction with unknown count → do not hold (prompt must show).
43
+ * - Otherwise hold until the count is met (or the liveness guard fires).
44
+ */
45
+ export declare function shouldHoldParallelStep(step: ParallelStepState, options?: {
46
+ fieldSeen?: boolean;
47
+ humanGatedWithoutCount?: boolean;
48
+ }): boolean;
49
+ /** True when the hold has made no progress for `idleMs`. */
50
+ export declare function shouldGuardCloseParallelStep(step: ParallelStepState, now?: number, idleMs?: number): boolean;
51
+ /**
52
+ * Frames that reset the liveness guard while holding. Heartbeat, KV, and
53
+ * checkpoint are not progress — they say nothing about further tool calls.
54
+ */
55
+ export declare function isParallelStepProgressFrame(kinds: {
56
+ toolRequestsListed?: boolean;
57
+ partialToolCall?: boolean;
58
+ toolCallDelta?: boolean;
59
+ toolCallStarted?: boolean;
60
+ toolCallCompleted?: boolean;
61
+ exec?: boolean;
62
+ interactionQuery?: boolean;
63
+ textDelta?: boolean;
64
+ thinkingDelta?: boolean;
65
+ }): boolean;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Parallel tool-call step boundary for OpenCode's AI SDK step model.
3
+ *
4
+ * Cursor fires execs as they are generated; CLI demuxes them concurrently and
5
+ * has no step boundary. OpenCode needs one `finish: tool-calls` after every
6
+ * call of the generation. Cursor's `tool_requests_listed` (InteractionUpdate
7
+ * field 27) carries that count. See tasks/plans/parallel-tool-calls-cli-parity.md.
8
+ */
9
+ /** Idle without a progress frame while holding → close the step (ms). */
10
+ export const PARALLEL_STEP_IDLE_MS = 1_500;
11
+ /** Process-level: Cursor has sent field 27 on any Run in this process. */
12
+ let toolRequestsListedSeen = false;
13
+ export function noteToolRequestsListedSeen() {
14
+ toolRequestsListedSeen = true;
15
+ }
16
+ export function hasSeenToolRequestsListed() {
17
+ return toolRequestsListedSeen;
18
+ }
19
+ /** Test-only: reset the process capability gate. */
20
+ export function resetToolRequestsListedSeenForTests() {
21
+ toolRequestsListedSeen = false;
22
+ }
23
+ export function createParallelStep(now = Date.now()) {
24
+ return {
25
+ callIds: new Set(),
26
+ resolved: new Set(),
27
+ emitted: 0,
28
+ lastProgressAt: now,
29
+ };
30
+ }
31
+ export function noteParallelStepProgress(step, now = Date.now()) {
32
+ step.lastProgressAt = now;
33
+ }
34
+ export function noteListedCallCount(step, callCount, now = Date.now()) {
35
+ noteToolRequestsListedSeen();
36
+ if (Number.isFinite(callCount) && callCount >= 0) {
37
+ step.listedCount = callCount;
38
+ }
39
+ noteParallelStepProgress(step, now);
40
+ }
41
+ /**
42
+ * Record a final disposition for `callId`. Returns true when newly resolved.
43
+ * Empty ids are ignored. Prior-step ids in `prior` are ignored.
44
+ */
45
+ export function resolveParallelStepCall(step, callId, prior, now = Date.now()) {
46
+ if (!callId)
47
+ return false;
48
+ if (prior?.has(callId))
49
+ return false;
50
+ step.callIds.add(callId);
51
+ if (step.resolved.has(callId))
52
+ return false;
53
+ step.resolved.add(callId);
54
+ noteParallelStepProgress(step, now);
55
+ return true;
56
+ }
57
+ export function recordParallelStepEmission(step, now = Date.now()) {
58
+ step.emitted++;
59
+ noteParallelStepProgress(step, now);
60
+ }
61
+ /** True when the listed count is known and every listed call is resolved. */
62
+ export function parallelStepCountMet(step) {
63
+ return step.listedCount !== undefined && step.resolved.size >= step.listedCount;
64
+ }
65
+ /**
66
+ * Whether to keep pumping for more calls of this step instead of finishing.
67
+ *
68
+ * - Gate off and no count yet → do not hold (today's one-call finish).
69
+ * - Human-gated interaction with unknown count → do not hold (prompt must show).
70
+ * - Otherwise hold until the count is met (or the liveness guard fires).
71
+ */
72
+ export function shouldHoldParallelStep(step, options = {}) {
73
+ if (options.humanGatedWithoutCount && step.listedCount === undefined)
74
+ return false;
75
+ const fieldSeen = options.fieldSeen ?? hasSeenToolRequestsListed();
76
+ if (!fieldSeen && step.listedCount === undefined)
77
+ return false;
78
+ if (parallelStepCountMet(step))
79
+ return false;
80
+ return true;
81
+ }
82
+ /** True when the hold has made no progress for `idleMs`. */
83
+ export function shouldGuardCloseParallelStep(step, now = Date.now(), idleMs = PARALLEL_STEP_IDLE_MS) {
84
+ if (parallelStepCountMet(step))
85
+ return false;
86
+ return now - step.lastProgressAt >= idleMs;
87
+ }
88
+ /**
89
+ * Frames that reset the liveness guard while holding. Heartbeat, KV, and
90
+ * checkpoint are not progress — they say nothing about further tool calls.
91
+ */
92
+ export function isParallelStepProgressFrame(kinds) {
93
+ return !!(kinds.toolRequestsListed
94
+ || kinds.partialToolCall
95
+ || kinds.toolCallDelta
96
+ || kinds.toolCallStarted
97
+ || kinds.toolCallCompleted
98
+ || kinds.exec
99
+ || kinds.interactionQuery
100
+ || kinds.textDelta
101
+ || kinds.thinkingDelta);
102
+ }