cursor-opencode-provider 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +143 -11
  2. package/dist/auth.d.ts +7 -0
  3. package/dist/auth.js +9 -0
  4. package/dist/compaction-marker.d.ts +14 -0
  5. package/dist/compaction-marker.js +36 -0
  6. package/dist/context/agents.d.ts +5 -1
  7. package/dist/context/agents.js +90 -30
  8. package/dist/context/build.js +57 -8
  9. package/dist/context/env.js +6 -2
  10. package/dist/context/paths.d.ts +10 -0
  11. package/dist/context/paths.js +24 -0
  12. package/dist/context/plugins.js +16 -12
  13. package/dist/context/rules.js +26 -23
  14. package/dist/context/skills.js +8 -3
  15. package/dist/index.d.ts +1 -3
  16. package/dist/index.js +2 -8
  17. package/dist/language-model.js +121 -35
  18. package/dist/model-config.d.ts +15 -0
  19. package/dist/model-config.js +188 -0
  20. package/dist/opencode2/catalog.d.ts +33 -0
  21. package/dist/opencode2/catalog.js +85 -0
  22. package/dist/opencode2/integration.d.ts +23 -0
  23. package/dist/opencode2/integration.js +124 -0
  24. package/dist/opencode2/types.d.ts +316 -0
  25. package/dist/opencode2/types.js +18 -0
  26. package/dist/plugin-core.d.ts +27 -0
  27. package/dist/plugin-core.js +31 -0
  28. package/dist/plugin-opencode2.d.ts +3 -0
  29. package/dist/plugin-opencode2.js +293 -0
  30. package/dist/plugin-v2.d.ts +7 -0
  31. package/dist/plugin-v2.js +1 -15
  32. package/dist/plugin.d.ts +1 -15
  33. package/dist/plugin.js +11 -191
  34. package/dist/protocol/messages.d.ts +10 -0
  35. package/dist/protocol/messages.js +76 -1
  36. package/dist/protocol/tool-call-bridge.js +22 -2
  37. package/dist/protocol/tools.d.ts +57 -1
  38. package/dist/protocol/tools.js +277 -17
  39. package/dist/session-directory.d.ts +17 -0
  40. package/dist/session-directory.js +35 -0
  41. package/dist/session.d.ts +16 -2
  42. package/dist/session.js +70 -1
  43. package/dist/shell-timeout.d.ts +3 -1
  44. package/dist/shell-timeout.js +6 -2
  45. package/dist/usage.d.ts +30 -0
  46. package/dist/usage.js +77 -0
  47. package/dist/web-search-tool.d.ts +33 -0
  48. package/dist/web-search-tool.js +22 -0
  49. package/dist/web-tools.d.ts +9 -25
  50. package/dist/web-tools.js +34 -36
  51. package/package.json +7 -2
@@ -4,6 +4,30 @@ import { homedir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { trace } from "../debug.js";
7
+ /** Host-neutral bridge installed by OCP before an unchanged provider is loaded. */
8
+ export const OPENCODE_PATH_BRIDGE = Symbol.for("opencode.compat.path-bridge");
9
+ function pathBridge() {
10
+ const value = globalThis[OPENCODE_PATH_BRIDGE];
11
+ if (!value || typeof value !== "object")
12
+ return undefined;
13
+ const bridge = value;
14
+ return typeof bridge.projectConfigDirs === "function" && typeof bridge.globalConfigDirs === "function"
15
+ ? bridge
16
+ : undefined;
17
+ }
18
+ export function opencodeProjectConfigDirs(workspaceRoot) {
19
+ return pathBridge()?.projectConfigDirs(path.resolve(workspaceRoot)) ?? [
20
+ path.join(path.resolve(workspaceRoot), ".opencode"),
21
+ ];
22
+ }
23
+ export function opencodeGlobalConfigDirs() {
24
+ return pathBridge()?.globalConfigDirs() ?? [opencodeGlobalConfigDir()];
25
+ }
26
+ export function opencodeConfigFileNames() {
27
+ return pathBridge()?.configFileNames?.length
28
+ ? [...pathBridge().configFileNames]
29
+ : ["opencode.json", "opencode.jsonc"];
30
+ }
7
31
  /** Explicit host cache root (e.g. Effect v2 `Path.cache`, or `createCursor({ cacheDir })`). */
8
32
  let hostCacheDirOverride;
9
33
  function resolveHome(env = process.env) {
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { opencodeGlobalConfigDir } from "./paths.js";
3
+ import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
4
4
  async function listLocalPlugins(dir) {
5
5
  try {
6
6
  await stat(dir);
@@ -34,17 +34,21 @@ export async function collectPlugins(workspaceRoot, config) {
34
34
  seen.add(id);
35
35
  out.push({ id, source: "npm" });
36
36
  }
37
- for (const p of await listLocalPlugins(path.join(workspaceRoot, ".opencode", "plugins"))) {
38
- if (seen.has(p.id))
39
- continue;
40
- seen.add(p.id);
41
- out.push(p);
42
- }
43
- for (const p of await listLocalPlugins(path.join(opencodeGlobalConfigDir(), "plugins"))) {
44
- if (seen.has(p.id))
45
- continue;
46
- seen.add(p.id);
47
- out.push(p);
37
+ for (const configDir of opencodeProjectConfigDirs(workspaceRoot)) {
38
+ for (const p of await listLocalPlugins(path.join(configDir, "plugins"))) {
39
+ if (seen.has(p.id))
40
+ continue;
41
+ seen.add(p.id);
42
+ out.push(p);
43
+ }
44
+ }
45
+ for (const configDir of opencodeGlobalConfigDirs()) {
46
+ for (const p of await listLocalPlugins(path.join(configDir, "plugins"))) {
47
+ if (seen.has(p.id))
48
+ continue;
49
+ seen.add(p.id);
50
+ out.push(p);
51
+ }
48
52
  }
49
53
  return out;
50
54
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile, readdir, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
- import { opencodeGlobalConfigDir, resolveHomeRelative } from "./paths.js";
4
+ import { opencodeConfigFileNames, opencodeGlobalConfigDirs, opencodeProjectConfigDirs, resolveHomeRelative } from "./paths.js";
5
5
  async function exists(file) {
6
6
  try {
7
7
  await stat(file);
@@ -12,7 +12,7 @@ async function exists(file) {
12
12
  }
13
13
  }
14
14
  async function readJsonConfig(dir) {
15
- for (const name of ["opencode.json", "opencode.jsonc"]) {
15
+ for (const name of opencodeConfigFileNames()) {
16
16
  const file = path.join(dir, name);
17
17
  if (!(await exists(file)))
18
18
  continue;
@@ -151,29 +151,30 @@ export async function fetchRemoteInstruction(url, timeoutMs = 5000) {
151
151
  clearTimeout(timer);
152
152
  }
153
153
  }
154
- export async function loadMergedConfig(workspaceRoot) {
155
- const globalConfig = await readJsonConfig(opencodeGlobalConfigDir());
156
- if (isProjectConfigDisabled()) {
157
- return {
158
- ...globalConfig,
159
- instructions: [...(globalConfig.instructions ?? [])],
160
- plugin: [...(globalConfig.plugin ?? [])],
161
- plugins: [...(globalConfig.plugins ?? [])],
162
- mcp: { ...(globalConfig.mcp ?? {}) },
163
- permission: globalConfig.permission,
164
- };
165
- }
166
- const projectConfig = await readJsonConfig(workspaceRoot);
154
+ function mergeConfig(base, overlay) {
167
155
  return {
168
- ...globalConfig,
169
- ...projectConfig,
170
- instructions: [...(globalConfig.instructions ?? []), ...(projectConfig.instructions ?? [])],
171
- plugin: [...new Set([...(globalConfig.plugin ?? []), ...(projectConfig.plugin ?? [])])],
172
- plugins: [...new Set([...(globalConfig.plugins ?? []), ...(projectConfig.plugins ?? [])])],
173
- mcp: { ...(globalConfig.mcp ?? {}), ...(projectConfig.mcp ?? {}) },
174
- permission: projectConfig.permission ?? globalConfig.permission,
156
+ ...base,
157
+ ...overlay,
158
+ instructions: [...(base.instructions ?? []), ...(overlay.instructions ?? [])],
159
+ plugin: [...new Set([...(base.plugin ?? []), ...(overlay.plugin ?? [])])],
160
+ plugins: [...new Set([...(base.plugins ?? []), ...(overlay.plugins ?? [])])],
161
+ mcp: { ...(base.mcp ?? {}), ...(overlay.mcp ?? {}) },
162
+ permission: overlay.permission ?? base.permission,
175
163
  };
176
164
  }
165
+ export async function loadMergedConfig(workspaceRoot) {
166
+ const globalConfig = await readJsonConfig(opencodeGlobalConfigDirs()[0] ?? "");
167
+ if (isProjectConfigDisabled())
168
+ return mergeConfig({}, globalConfig);
169
+ // The bridge supplies native project config roots for an unchanged plugin:
170
+ // .opencode on OpenCode, .mimocode on MiMo, and .kilo/.kilocode on Kilo.
171
+ // Later roots have higher precedence, matching the host's native ordering.
172
+ let projectConfig = await readJsonConfig(workspaceRoot);
173
+ for (const configDir of opencodeProjectConfigDirs(workspaceRoot)) {
174
+ projectConfig = mergeConfig(projectConfig, await readJsonConfig(configDir));
175
+ }
176
+ return mergeConfig(globalConfig, projectConfig);
177
+ }
177
178
  /**
178
179
  * Collect OpenCode instruction files.
179
180
  */
@@ -205,7 +206,9 @@ export async function collectRules(workspaceRoot) {
205
206
  }
206
207
  }
207
208
  }
208
- await add(path.join(opencodeGlobalConfigDir(), "AGENTS.md"));
209
+ for (const globalDir of opencodeGlobalConfigDirs()) {
210
+ await add(path.join(globalDir, "AGENTS.md"));
211
+ }
209
212
  await add(path.join(homedir(), ".claude", "CLAUDE.md"));
210
213
  for (const raw of config.instructions ?? []) {
211
214
  if (raw.startsWith("http://") || raw.startsWith("https://")) {
@@ -1,7 +1,7 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { homedir } from "node:os";
4
- import { opencodeGlobalConfigDir } from "./paths.js";
4
+ import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
5
5
  async function exists(p) {
6
6
  try {
7
7
  await stat(p);
@@ -94,8 +94,13 @@ async function walkAncestorsFor(dir, rel, stop, out) {
94
94
  export async function collectSkills(workspaceRoot, worktree) {
95
95
  const out = new Map();
96
96
  const home = homedir();
97
- await walkAncestorsFor(workspaceRoot, path.join(".opencode", "skills"), worktree, out);
98
- await scanSkillsRoot(path.join(opencodeGlobalConfigDir(), "skills"), out);
97
+ for (const projectDir of opencodeProjectConfigDirs(workspaceRoot)) {
98
+ const relative = path.relative(workspaceRoot, projectDir);
99
+ await walkAncestorsFor(workspaceRoot, path.join(relative, "skills"), worktree, out);
100
+ }
101
+ for (const globalDir of opencodeGlobalConfigDirs()) {
102
+ await scanSkillsRoot(path.join(globalDir, "skills"), out);
103
+ }
99
104
  await walkAncestorsFor(workspaceRoot, path.join(".claude", "skills"), worktree, out);
100
105
  await scanSkillsRoot(path.join(home, ".claude", "skills"), out);
101
106
  await walkAncestorsFor(workspaceRoot, path.join(".agents", "skills"), worktree, out);
package/dist/index.d.ts CHANGED
@@ -34,9 +34,7 @@ export type CreateCursorOptions = {
34
34
  /** Fresh-turn retry policy. Defaults: 3 attempts, 500ms base, 8000ms cap. */
35
35
  retry?: CursorRetryOptions;
36
36
  };
37
- export declare function createCursor(options: CreateCursorOptions): {
38
- languageModel(modelId: string): import("@ai-sdk/provider").LanguageModelV3;
39
- };
37
+ export declare function createCursor(options: CreateCursorOptions): import("./plugin-core.js").CursorSdk;
40
38
  export { CursorPlugin };
41
39
  export type { CursorContinuationOptions, CursorContinuationPolicy } from "./session.js";
42
40
  export default CursorPlugin;
package/dist/index.js CHANGED
@@ -1,13 +1,7 @@
1
- import { CURSOR_PROVIDER_ID } from "./shared.js";
2
- import { createCursorLanguageModel } from "./language-model.js";
1
+ import { createSdk } from "./plugin-core.js";
3
2
  import { CursorPlugin } from "./plugin.js";
4
3
  export function createCursor(options) {
5
- const providerId = options.name || CURSOR_PROVIDER_ID;
6
- return {
7
- languageModel(modelId) {
8
- return createCursorLanguageModel(modelId, providerId, options);
9
- },
10
- };
4
+ return createSdk(options);
11
5
  }
12
6
  export { CursorPlugin };
13
7
  export default CursorPlugin;
@@ -5,8 +5,8 @@ import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } fr
5
5
  import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
- import { decodeMessage } from "./protocol/messages.js";
9
- import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, resolveCustomWebToolAlias, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
8
+ import { debugWalkTurnEnded, decodeMessage } from "./protocol/messages.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
10
10
  import { describeCursorExecVariant } from "./protocol/exec-variants.js";
11
11
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
12
  import { handleKvServerMessage } from "./protocol/kv.js";
@@ -22,9 +22,12 @@ import { workspaceRootFromRequestContext } from "./context/env.js";
22
22
  import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
23
23
  import { resolveAgentUrl } from "./agent-url.js";
24
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
25
+ import { isCompactionSession } from "./compaction-marker.js";
26
+ import { getSessionDirectory } from "./session-directory.js";
25
27
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
26
28
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
27
29
  import { readAllFieldsStrict } from "./protocol/struct.js";
30
+ import { buildLanguageModelV3UsageFromEstimate, buildLanguageModelV3UsageFromTurnEnded, turnEndedCounter, } from "./usage.js";
28
31
  let _availableModels;
29
32
  // mtime of the cache file the last time we loaded it. Compared on each call
30
33
  // so discoverModels' background refresh is picked up without a process restart.
@@ -275,7 +278,7 @@ export function createCursorLanguageModel(modelId, providerId, options) {
275
278
  };
276
279
  }
277
280
  async function doStreamImpl(modelId, options, callOptions) {
278
- // A raw `sk-...` API key must be exchanged for a JWT before it can be used
281
+ // A raw `crsr_...` API key must be exchanged for a JWT before it can be used
279
282
  // as a Bearer token (the plugin path does this in auth.ts). The accessToken
280
283
  // path is already a JWT from OAuth/key-exchange, so we use it as-is.
281
284
  // resolveBearerToken caches apiKey exchanges so we don't hit /auth/exchange
@@ -445,8 +448,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
445
448
  const sessionKey = opencodeSessionKey(callOptions);
446
449
  const providerOptions = callOptions.providerOptions?.cursor;
447
450
  // The classic plugin marks OpenCode's agent="compaction" through chat.params.
451
+ // OpenCode 2.0 removed that hook, so its plugin records the same fact against
452
+ // the session id from session.hook("context") instead; consult both.
448
453
  // Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
449
- const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true;
454
+ const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
450
455
  const toolState = resolveTurnToolState({
451
456
  sessionKey,
452
457
  incomingTools,
@@ -463,6 +468,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
463
468
  trace(`web tool alias: ${alias} -> ${original}`);
464
469
  }
465
470
  const allowTools = toolState.allowTools;
471
+ const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
466
472
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
467
473
  const recovery = startOptions?.recovery;
468
474
  const resuming = recovery?.kind === "resume";
@@ -481,7 +487,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
481
487
  const userText = recovery?.kind === "rebase"
482
488
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
483
489
  : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
484
- const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
490
+ // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
491
+ // one plugin instance per project). OpenCode 2.0 runs one daemon across many
492
+ // projects, so its static `options.workspaceRoot` is only a last-resort
493
+ // fallback; `getSessionDirectory` carries the real per-session directory
494
+ // recorded from `session.hook("context")` in `plugin-opencode2.ts`.
495
+ const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
485
496
  const baseSystemPrompt = extractSystemPrompt(prompt);
486
497
  const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
487
498
  const systemPrompt = interactionGuidance
@@ -526,6 +537,23 @@ async function startSession(modelId, token, callOptions, options, startOptions)
526
537
  headers: options.headers,
527
538
  });
528
539
  const requestContext = await buildRequestContext({ workspaceRoot, tools: cursorTools });
540
+ const contextSubagents = Array.isArray(requestContext.custom_subagents)
541
+ ? requestContext.custom_subagents
542
+ .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
543
+ ? {
544
+ name: agent.name,
545
+ description: typeof agent.description === "string"
546
+ ? agent.description
547
+ : undefined,
548
+ }
549
+ : undefined)
550
+ .filter((agent) => !!agent)
551
+ : [];
552
+ const subagentCatalog = {
553
+ ...discoveredSubagentCatalog,
554
+ agents: [...new Map([...discoveredSubagentCatalog.agents, ...contextSubagents]
555
+ .map((agent) => [agent.name, agent])).values()],
556
+ };
529
557
  // Resolve descriptors once from the merged OpenCode config so MCP identity is
530
558
  // consistent across AgentRunRequest and both request_context reply paths.
531
559
  const toolDescriptors = Array.isArray(requestContext.tools)
@@ -565,6 +593,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
565
593
  outputTokens: 0,
566
594
  cacheRead: 0,
567
595
  cacheWrite: 0,
596
+ reasoningTokens: 0,
568
597
  };
569
598
  trace(`outbound Run: model=${cursorModelId} opencodeModel=${modelId} conversationId=${conversationId} ` +
570
599
  `params=${JSON.stringify(parameterValues ?? [])} ` +
@@ -603,6 +632,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
603
632
  blobs: new Map(),
604
633
  toolDescriptors,
605
634
  toolAliases: webToolAliases.aliases,
635
+ subagentCatalog,
606
636
  requestContext,
607
637
  allowTools,
608
638
  usageEstimate,
@@ -711,6 +741,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
711
741
  toolName: pending.toolName ?? r.toolName,
712
742
  resultMetadata: pending.resultMetadata,
713
743
  shellOutcome: shellResult?.outcome,
744
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
714
745
  });
715
746
  }
716
747
  catch (error) {
@@ -897,6 +928,7 @@ export async function pump(session, controller, ids, abortSignal) {
897
928
  error: reason,
898
929
  toolName: parsed.toolName,
899
930
  resultMetadata: parsed.resultMetadata,
931
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
900
932
  })) {
901
933
  session.stream.write(frame);
902
934
  }
@@ -946,6 +978,7 @@ export async function pump(session, controller, ids, abortSignal) {
946
978
  output: "",
947
979
  toolName: parsed.toolName,
948
980
  resultMetadata: { path: requestedPath },
981
+ workspaceRoot,
949
982
  })) {
950
983
  session.stream.write(frame);
951
984
  }
@@ -961,6 +994,49 @@ export async function pump(session, controller, ids, abortSignal) {
961
994
  return true;
962
995
  }
963
996
  };
997
+ /**
998
+ * A read of a path that does not exist would otherwise reach OpenCode as a
999
+ * real tool call and raise a permission prompt for a file the user never had.
1000
+ * Cursor's own LocalReadExecutor instead resolves the target against the
1001
+ * workspace, stats it, and returns a typed ReadResult (file_not_found /
1002
+ * invalid_file) before execution. Mirror that here: reply on the held-open
1003
+ * exec channel with the exact typed case so the model receives a structured
1004
+ * observation and the host surfaces no prompt.
1005
+ *
1006
+ * Read-only by construction — write/edit targets are never checked, so
1007
+ * legitimate file creation (path resolves, file absent) is never denied. Runs
1008
+ * after recoverMissingEditRead, which has already converted the new-file edit
1009
+ * handshake read into an empty-file success. EACCES/EPERM and other stat
1010
+ * errors fall through to OpenCode so a genuine permission decision stands.
1011
+ */
1012
+ const rejectMissingReadTarget = (parsed) => {
1013
+ if (parsed.toolName !== "read")
1014
+ return false;
1015
+ const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
1016
+ if (!requested)
1017
+ return false;
1018
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
1019
+ const absolutePath = resolveReadTargetPath(requested, workspaceRoot);
1020
+ const readResult = classifyMissingReadTarget(absolutePath);
1021
+ if (!readResult)
1022
+ return false;
1023
+ try {
1024
+ for (const frame of buildReadRejectionMessages(parsed.id, readResult)) {
1025
+ session.stream.write(frame);
1026
+ }
1027
+ const kind = Object.keys(readResult)[0];
1028
+ trace(`exec: rejected missing read target id=${parsed.id} kind=${kind} ` +
1029
+ `path=${JSON.stringify(absolutePath)}`);
1030
+ return true;
1031
+ }
1032
+ catch (e) {
1033
+ const error = new Error(`Failed to reject Cursor read of a missing path: ${e.message}`);
1034
+ trace(`exec: missing read rejection FAILED ${error.message}`);
1035
+ safeError(error);
1036
+ sessionManager.close(session);
1037
+ return true;
1038
+ }
1039
+ };
964
1040
  /** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
965
1041
  const closeOpenSpans = () => {
966
1042
  for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
@@ -1005,32 +1081,27 @@ export async function pump(session, controller, ids, abortSignal) {
1005
1081
  if (te) {
1006
1082
  // Authoritative TurnEnded counts — replace the running estimate.
1007
1083
  session.usageEstimate = {
1008
- inputTokens: Number(te.input_tokens ?? 0) || 0,
1009
- outputTokens: Number(te.output_tokens ?? 0) || 0,
1010
- cacheRead: Number(te.cache_read ?? 0) || 0,
1011
- cacheWrite: Number(te.cache_write ?? 0) || 0,
1084
+ inputTokens: turnEndedCounter(te, "input_tokens"),
1085
+ outputTokens: turnEndedCounter(te, "output_tokens"),
1086
+ cacheRead: turnEndedCounter(te, "cache_read"),
1087
+ cacheWrite: turnEndedCounter(te, "cache_write"),
1088
+ reasoningTokens: turnEndedCounter(te, "reasoning_tokens"),
1012
1089
  };
1013
1090
  }
1014
1091
  const est = session.usageEstimate;
1015
- const usage = {
1016
- inputTokens: {
1017
- total: promptTokens,
1018
- noCache: undefined,
1019
- cacheRead: 0,
1020
- cacheWrite: 0,
1021
- },
1022
- outputTokens: {
1023
- total: estimateTokens(requestUsage.outputChars),
1024
- text: undefined,
1025
- reasoning: undefined,
1026
- },
1027
- };
1092
+ const usage = te
1093
+ ? buildLanguageModelV3UsageFromTurnEnded(te)
1094
+ : buildLanguageModelV3UsageFromEstimate(est, promptTokens, requestUsage.outputChars, estimateTokens);
1028
1095
  const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
1029
1096
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1030
1097
  ? String(reason.unified ?? "unknown")
1031
1098
  : String(reason);
1099
+ const inTotal = usage.inputTokens?.total ?? 0;
1100
+ const outTotal = usage.outputTokens?.total ?? 0;
1032
1101
  trace(`finish: reason=${reasonLabel} ` +
1033
- `requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
1102
+ `v3In=${inTotal} v3Out=${outTotal} ` +
1103
+ `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
1104
+ `v3Reasoning=${usage.outputTokens?.reasoning ?? 0} ` +
1034
1105
  `rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
1035
1106
  `rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
1036
1107
  `source=${te ? "turn_ended" : "estimate"}`);
@@ -1176,6 +1247,7 @@ export async function pump(session, controller, ids, abortSignal) {
1176
1247
  emitReasoning(iu.thinking_delta.text ?? "");
1177
1248
  }
1178
1249
  else if (iu?.turn_ended) {
1250
+ trace(`turn_ended raw wire fields: ${debugWalkTurnEnded(payload)}`);
1179
1251
  emitFinish(iu.turn_ended, { unified: "stop", raw: undefined });
1180
1252
  sessionManager.close(session);
1181
1253
  return;
@@ -1304,6 +1376,7 @@ export async function pump(session, controller, ids, abortSignal) {
1304
1376
  trace(`web tool alias resolved: ${parsed.toolName} -> ${executableToolName}`);
1305
1377
  parsed.toolName = executableToolName;
1306
1378
  }
1379
+ remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
1307
1380
  }
1308
1381
  const displayCallId = extractExecDisplayCallId(esm);
1309
1382
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
@@ -1344,6 +1417,11 @@ export async function pump(session, controller, ids, abortSignal) {
1344
1417
  }
1345
1418
  if (recoverMissingEditRead(parsed, displayCallId))
1346
1419
  continue;
1420
+ if (rejectMissingReadTarget(parsed)) {
1421
+ if (displayCallId)
1422
+ session.displayToolCalls.delete(displayCallId);
1423
+ continue;
1424
+ }
1347
1425
  if (displayCallId) {
1348
1426
  session.displayToolCalls.delete(displayCallId);
1349
1427
  trace(`exec: claimed display callId=${displayCallId}`);
@@ -1550,6 +1628,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1550
1628
  if (names.size === 0)
1551
1629
  return undefined;
1552
1630
  const instructions = [];
1631
+ const subagents = extractHostSubagentCatalog(tools);
1553
1632
  if (names.has("question")) {
1554
1633
  instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
1555
1634
  }
@@ -1568,6 +1647,17 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1568
1647
  if (names.has(CUSTOM_WEBFETCH_TOOL)) {
1569
1648
  instructions.push(`- To fetch a known URL, call \`${CUSTOM_WEBFETCH_TOOL}\`; do not use Cursor's native WebFetch interaction.`);
1570
1649
  }
1650
+ if (names.has("task") || names.has("actor")) {
1651
+ const target = names.has("actor") ? "`actor`" : "`task`";
1652
+ const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
1653
+ instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
1654
+ "Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
1655
+ "`bugbot`, `security-review`, and `explore` select host `explore` (then `general`), and other specialized Cursor types fall back to `general`." +
1656
+ (available ? ` Spawnable host agents this turn: ${available}.` : ""));
1657
+ if (subagents.agents.some((agent) => agent.name === "scout")) {
1658
+ instructions.push("- Host `scout` is available for external documentation and dependency-source research. Use Cursor `cursor-guide` for that use case; local repository discovery still uses `bugbot`/`explore`.");
1659
+ }
1660
+ }
1571
1661
  if (names.has("write")) {
1572
1662
  instructions.push(names.has("edit")
1573
1663
  ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
@@ -1576,7 +1666,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1576
1666
  return [
1577
1667
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1578
1668
  `Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
1579
- "Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
1669
+ subagents.executor
1670
+ ? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed; other unlisted Cursor-native tools remain unavailable."
1671
+ : "Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
1580
1672
  ...(instructions.length > 0
1581
1673
  ? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
1582
1674
  : []),
@@ -1601,20 +1693,14 @@ export function estimatePromptTokens(prompt) {
1601
1693
  }
1602
1694
  /** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
1603
1695
  export function cursorTurnEndedProviderMetadata(te) {
1604
- const counter = (key) => {
1605
- const value = te[key];
1606
- return typeof value === "number" && Number.isFinite(value) && value >= 0
1607
- ? Math.trunc(value)
1608
- : 0;
1609
- };
1610
1696
  return {
1611
1697
  cursor: {
1612
1698
  usageVersion: 2,
1613
- inputTokensRaw: counter("input_tokens"),
1614
- outputTokensRaw: counter("output_tokens"),
1615
- cacheReadRaw: counter("cache_read"),
1616
- cacheWriteRaw: counter("cache_write"),
1617
- reasoningTokensRaw: counter("reasoning_tokens"),
1699
+ inputTokensRaw: turnEndedCounter(te, "input_tokens"),
1700
+ outputTokensRaw: turnEndedCounter(te, "output_tokens"),
1701
+ cacheReadRaw: turnEndedCounter(te, "cache_read"),
1702
+ cacheWriteRaw: turnEndedCounter(te, "cache_write"),
1703
+ reasoningTokensRaw: turnEndedCounter(te, "reasoning_tokens"),
1618
1704
  },
1619
1705
  };
1620
1706
  }
@@ -0,0 +1,15 @@
1
+ import { type ModelInfo } from "./models.js";
2
+ /**
3
+ * Display names shared by both a thinking and a non-thinking model. A thinking
4
+ * model with such a name needs a "Thinking" tag to disambiguate it from its
5
+ * non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
6
+ * are already unique — including Cursor's GPT family, where the reasoning tier
7
+ * ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
8
+ * tagged redundantly.
9
+ */
10
+ export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
11
+ export declare function modelInfoToConfig(mi: ModelInfo, options?: {
12
+ thinkingSuffix?: boolean;
13
+ contextTier?: "base" | "long";
14
+ }): Record<string, any>;
15
+ export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;