cursor-opencode-provider 0.7.4 → 0.7.6

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.
@@ -32,6 +32,9 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
32
32
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
33
33
  import { readCache, cacheFilePath, resolveVariantParameters, resolveVariantMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
34
34
  import { getOrBuildRequestContext } from "./context/frozen.js";
35
+ import { getHeldOverlaySkills } from "./context/overlay.js";
36
+ import { loadMergedConfig } from "./context/rules.js";
37
+ import { buildDynamicCatalogRoutingInstruction, takeSkillCatalogChangeReminder, } from "./context/dynamic-catalog.js";
35
38
  import { admitContextEpoch, appendMidConversationMessage, resetContextEpochsForTests, } from "./context/epoch.js";
36
39
  import { workspaceRootFromRequestContext } from "./context/env.js";
37
40
  import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
@@ -507,6 +510,12 @@ async function doStreamImpl(modelId, options, callOptions) {
507
510
  // false "orphaned tool results" errors after Cursor turn_ended and OpenCode
508
511
  // started the next step with old tools still in the prompt body.
509
512
  const trailingToolResults = extractTrailingToolResults(prompt);
513
+ // A host may submit an already rendered plan through its canonical stage
514
+ // tool after the Cursor Run has ended. The next call carries that tool result
515
+ // without a Cursor exec id; reconcile the approved mode before a fresh Run.
516
+ if (hasApprovedUncorrelatedPlanStageResult(prompt)) {
517
+ setActiveCursorMode(opencodeSessionKey(callOptions), "agent");
518
+ }
510
519
  let session = findContinuationSession(trailingToolResults);
511
520
  if (session) {
512
521
  // Write pending results onto the held-open Run. A dead stream closes the
@@ -533,19 +542,34 @@ async function doStreamImpl(modelId, options, callOptions) {
533
542
  if (historical > 0) {
534
543
  trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
535
544
  }
536
- // Host may start a new user turn while the prior Run still has pending
537
- // tools. Finish that Run on the same conversation (cancel stranded execs,
538
- // drain turn_ended) before opening the new Run so the checkpoint prefix
539
- // is preserved. registerSession will not close a prior Run that still
540
- // has real pending execs; a failed prepare leaves that Run held.
541
- try {
542
- await preparePriorSessionForFreshTurn(sessionKey);
545
+ // An in-session helper (title/memory/task child) that reuses the parent
546
+ // OpenCode session id with a strictly smaller catalog must not cancel or
547
+ // supersede the held parent Run — that remints (`interrupted-run`) when
548
+ // the trailing tool result returns. Isolate onto an ephemeral conversation.
549
+ const busyPrior = sessionManager.findOpenByOpenCodeSessionId(sessionKey);
550
+ if (busyPrior
551
+ && !busyPrior.closed
552
+ && isProperCatalogSubset(extractTools(callOptions), busyPrior.toolCatalog ?? [])) {
553
+ trace(`fresh turn: isolating in-session helper — incomingTools subset of ` +
554
+ `parentTools=${busyPrior.toolCatalog?.length ?? 0} ` +
555
+ `priorSession=${busyPrior.sessionId} pending=${busyPrior.pending.size}`);
556
+ session = await openSession({ isolate: true });
543
557
  }
544
- catch (error) {
545
- trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
546
- `any still-pending prior held — ${error.message}`);
558
+ else {
559
+ // Host may start a new user turn while the prior Run still has pending
560
+ // tools. Finish that Run on the same conversation (cancel stranded execs,
561
+ // drain turn_ended) before opening the new Run so the checkpoint prefix
562
+ // is preserved. registerSession will not close a prior Run that still
563
+ // has real pending execs; a failed prepare leaves that Run held.
564
+ try {
565
+ await preparePriorSessionForFreshTurn(sessionKey);
566
+ }
567
+ catch (error) {
568
+ trace(`fresh turn: prepare-prior failed — opening the new Run and leaving ` +
569
+ `any still-pending prior held — ${error.message}`);
570
+ }
571
+ session = await openSession();
547
572
  }
548
- session = await openSession();
549
573
  }
550
574
  }
551
575
  let activeSession = session;
@@ -747,6 +771,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
747
771
  const prompt = callOptions.prompt;
748
772
  const incomingTools = extractTools(callOptions);
749
773
  const sessionKey = opencodeSessionKey(callOptions);
774
+ const isolateHelper = startOptions?.isolate === true;
750
775
  const cacheDir = opencodeGlobalCacheDir();
751
776
  if (sessionKey) {
752
777
  const restored = await hydrateConversationState(cacheDir, sessionKey).catch((error) => {
@@ -798,18 +823,28 @@ async function startSession(modelId, token, callOptions, options, startOptions)
798
823
  let resumeRecovery = recovery?.kind === "resume" ? recovery : undefined;
799
824
  let resuming = !!resumeRecovery;
800
825
  const lifecycle = !allowTools && !isCompaction && !recovery;
826
+ // Isolated helpers must not share the parent's openCodeSessionId or sticky
827
+ // conversation: registerSession would otherwise supersede the held parent Run.
828
+ const ephemeralRun = lifecycle || isolateHelper;
801
829
  // v1 sets `options.workspaceRoot` correctly per invocation (`input.directory`,
802
830
  // one plugin instance per project). OpenCode 2.0 runs one daemon across many
803
831
  // projects, so its static option is only a last-resort fallback. Prefer the
804
- // per-request `x-opencode-directory` header, then the session mark recorded
805
- // from `session.hook("context")` via `getSessionDirectory`.
832
+ // per-request `x-opencode-directory` header (set by the 2.0 plugin's
833
+ // `session.hook("model.request")`), then the session mark recorded from the
834
+ // session hooks via `getSessionDirectory`.
806
835
  const workspaceRoot = resolveSessionWorkspaceRoot({
807
836
  sessionKey,
808
837
  headers: callOptions.headers,
809
838
  workspaceRoot: options.workspaceRoot,
810
839
  });
811
840
  const baseSystemPrompt = extractSystemPrompt(prompt);
812
- const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
841
+ // One merged-config load per Run: guidance MCP ids and RequestContext
842
+ // descriptors must agree, and warm turns must not pay for a second disk read.
843
+ const mergedConfig = isCompaction ? undefined : await loadMergedConfig(workspaceRoot);
844
+ const knownMcpServers = Object.keys(mergedConfig?.mcp ?? {});
845
+ const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot, {
846
+ knownMcpServers,
847
+ });
813
848
  // Prompt-identity diagnostics are filled after Context Epoch admission below
814
849
  // (baseline hash is the epoch baseline, not a per-turn host hash).
815
850
  let frozenSystemPromptHash;
@@ -825,9 +860,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
825
860
  ? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
826
861
  : bindConversationId(sessionKey, {
827
862
  reset: resetState.reset || recovery?.kind === "rebase",
828
- ephemeral: lifecycle,
863
+ ephemeral: ephemeralRun,
829
864
  });
830
- let conversationState = lifecycle
865
+ let conversationState = ephemeralRun
831
866
  ? undefined
832
867
  : resuming
833
868
  ? resumeRecovery.checkpoint
@@ -987,7 +1022,20 @@ async function startSession(modelId, token, callOptions, options, startOptions)
987
1022
  });
988
1023
  // Freeze RequestContext per conversation_id (CLI parity). Rebuilding every
989
1024
  // Run mutates volatile slices (git porcelain, layout) and breaks prompt cache.
990
- const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools });
1025
+ const { context: requestContext, reused: requestContextReused } = await getOrBuildRequestContext(conversationId, { workspaceRoot, tools: cursorTools, mergedConfig });
1026
+ // Issue #29: after skills are in RequestContext, admit the catalog. OpenCode
1027
+ // only Mid-Conversation-updates when the available-skills list changes
1028
+ // (SkillGuidance / SkillInstructions) — never per-turn matched-id nudges.
1029
+ // Gate on the host-permitted set, not the epoch-held advertisement.
1030
+ if (!isCompaction && !lifecycle) {
1031
+ const skillDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1032
+ const skillNudge = takeSkillCatalogChangeReminder(conversationId, {
1033
+ hasSkillTool: allowTools && incomingTools.some((tool) => tool.name === "skill"),
1034
+ skills: getHeldOverlaySkills(conversationId),
1035
+ skillArgKey: skillDialect.skillArgKey,
1036
+ });
1037
+ userText = appendMidConversationMessage(userText, skillNudge);
1038
+ }
991
1039
  const contextSubagents = Array.isArray(requestContext.custom_subagents)
992
1040
  ? requestContext.custom_subagents
993
1041
  .map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
@@ -1097,7 +1145,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1097
1145
  }
1098
1146
  const hostToolDialect = hostToolDialectFromTools(tools, options.defaultDialect);
1099
1147
  trace(`host tool dialect: filePathKey=${hostToolDialect.filePathKey} shellTool=${hostToolDialect.shellTool} ` +
1100
- `tools=[${tools.map((t) => t.name).join(",")}]`);
1148
+ `skillArgKey=${hostToolDialect.skillArgKey} tools=[${tools.map((t) => t.name).join(",")}]`);
1101
1149
  const session = {
1102
1150
  sessionId: crypto.randomUUID(),
1103
1151
  conversationId,
@@ -1125,7 +1173,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1125
1173
  createPlanInTurn: false,
1126
1174
  switchModeInTurn: false,
1127
1175
  },
1128
- openCodeSessionId: lifecycle ? undefined : sessionKey,
1176
+ openCodeSessionId: ephemeralRun ? undefined : sessionKey,
1129
1177
  hostAgent,
1130
1178
  stableSystemPromptHash: frozenSystemPromptHash,
1131
1179
  postCompactionRebase: isCompaction,
@@ -1138,7 +1186,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
1138
1186
  // Seed from the per-OpenCode-session copy: merges in this turn (and after
1139
1187
  // checkpoint resumes/rebases, which rebuild the session here) expand
1140
1188
  // against the last observed host list, not an empty one.
1141
- mirroredTodos: snapshotMirroredTodosBySession(lifecycle ? undefined : sessionKey),
1189
+ mirroredTodos: snapshotMirroredTodosBySession(ephemeralRun ? undefined : sessionKey),
1142
1190
  nextBridgedExecId: 900_000,
1143
1191
  blobs: new Map(),
1144
1192
  toolDescriptors,
@@ -1242,6 +1290,39 @@ export function findContinuationSession(toolResults) {
1242
1290
  }
1243
1291
  return undefined;
1244
1292
  }
1293
+ /**
1294
+ * True when `incoming` is a non-empty proper subset of `parent` by tool name.
1295
+ * Used to spot in-session helpers that reuse the parent OpenCode session id
1296
+ * with a reduced catalog (e.g. stripping `task` / `question` / `plan_exit`).
1297
+ */
1298
+ export function isProperCatalogSubset(incoming, parent) {
1299
+ if (incoming.length === 0 || parent.length === 0)
1300
+ return false;
1301
+ if (incoming.length >= parent.length)
1302
+ return false;
1303
+ const parentNames = new Set(parent.map((tool) => tool.name).filter((name) => !!name));
1304
+ if (parentNames.size === 0)
1305
+ return false;
1306
+ let matched = 0;
1307
+ for (const tool of incoming) {
1308
+ const name = tool.name;
1309
+ if (!name || !parentNames.has(name))
1310
+ return false;
1311
+ matched++;
1312
+ }
1313
+ return matched > 0 && matched < parentNames.size;
1314
+ }
1315
+ /**
1316
+ * An open parent Run for this OpenCode session whose catalog strictly contains
1317
+ * the incoming tools is treated as an in-session helper. Those calls must not
1318
+ * cancel/supersede the parent (that remints on the trailing tool result).
1319
+ */
1320
+ export function shouldIsolateInSessionHelper(openCodeSessionId, incomingTools) {
1321
+ const prior = sessionManager.findOpenByOpenCodeSessionId(openCodeSessionId);
1322
+ if (!prior || prior.closed)
1323
+ return false;
1324
+ return isProperCatalogSubset(incomingTools, prior.toolCatalog ?? []);
1325
+ }
1245
1326
  /** How long a fresh-turn drain may wait for Cursor `turn_ended` after bridged settle. */
1246
1327
  export const FRESH_TURN_DRAIN_TIMEOUT_MS = 8_000;
1247
1328
  /**
@@ -1836,6 +1917,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
1836
1917
  });
1837
1918
  }
1838
1919
  }
1920
+ // A directly called advertised plan-stage tool can own the same review
1921
+ // gate as a bridged CreatePlan. Its successful result means execution was
1922
+ // approved, so the next Run must carry agent-mode guidance.
1923
+ if (!pending.bridged
1924
+ && pending.toolName === CURSOR_PLAN_STAGE_TOOL
1925
+ && r.error === undefined) {
1926
+ setActiveCursorMode(session.openCodeSessionId, "agent");
1927
+ }
1839
1928
  // A host `todoread` result is the authoritative list. Refresh the mirrored
1840
1929
  // snapshot (direct reads and bridged native reads alike) so later merges
1841
1930
  // apply onto host truth, not a stale write.
@@ -2864,7 +2953,7 @@ export async function pump(session, controller, ids, abortSignal) {
2864
2953
  imageId = stageCursorImage({
2865
2954
  path: target,
2866
2955
  projectDir,
2867
- mime: imageMimeForPath(target),
2956
+ mime: imageMimeForPath(target, binaryWrite.data),
2868
2957
  data: binaryWrite.data,
2869
2958
  sessionId: session.openCodeSessionId,
2870
2959
  });
@@ -3299,6 +3388,25 @@ export function extractTrailingToolResults(prompt) {
3299
3388
  return [];
3300
3389
  return extractToolResults(prompt.slice(i + 1));
3301
3390
  }
3391
+ /** Detect a host-owned canonical plan review, excluding Cursor exec replies. */
3392
+ export function hasApprovedUncorrelatedPlanStageResult(prompt) {
3393
+ if (prompt.length === 0 || prompt[prompt.length - 1].role !== "tool")
3394
+ return false;
3395
+ for (let i = prompt.length - 1; i >= 0 && prompt[i].role === "tool"; i--) {
3396
+ const message = prompt[i];
3397
+ if (!Array.isArray(message.content))
3398
+ continue;
3399
+ for (const part of message.content) {
3400
+ if (part.type !== "tool-result" || part.toolName !== CURSOR_PLAN_STAGE_TOOL)
3401
+ continue;
3402
+ if (parseExecIdFromToolCallId(part.toolCallId))
3403
+ continue;
3404
+ const result = toolResultOutputToText(part.output);
3405
+ return !result.isError;
3406
+ }
3407
+ }
3408
+ return false;
3409
+ }
3302
3410
  function toolResultOutputToText(output) {
3303
3411
  if (output == null)
3304
3412
  return { text: "", isError: false };
@@ -3351,7 +3459,7 @@ export function groundCheckpointTurnText(userText, checkpoint, workspaceRoot, to
3351
3459
  * Redirect only to OpenCode tools that are genuinely advertised this turn;
3352
3460
  * compaction keeps its dedicated summary prompt unchanged.
3353
3461
  */
3354
- export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
3462
+ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot, options = {}) {
3355
3463
  if (isCompaction)
3356
3464
  return undefined;
3357
3465
  const names = new Set(tools.map((tool) => tool.name));
@@ -3419,6 +3527,16 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
3419
3527
  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`.");
3420
3528
  }
3421
3529
  }
3530
+ // Issue #29: skill/MCP stay off Cursor's native top-level list on both
3531
+ // OpenCode 1.x and 2.0 — route them through the dynamic catalog instead.
3532
+ {
3533
+ const dynamicCatalog = buildDynamicCatalogRoutingInstruction({
3534
+ toolNames: tools.map((tool) => tool.sourceName ?? tool.name),
3535
+ knownMcpServers: options.knownMcpServers,
3536
+ });
3537
+ if (dynamicCatalog)
3538
+ instructions.push(dynamicCatalog);
3539
+ }
3422
3540
  if (names.has("write")) {
3423
3541
  instructions.push(names.has("edit")
3424
3542
  ? "- 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."
@@ -3,8 +3,13 @@ import type { ToolDraft } from "./types.js";
3
3
  * OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
4
4
  * config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
5
5
  * `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
6
- * the AI SDK catalog (`packages/core/src/tool.ts`). Cursor can call a tool
7
- * only when it is in that catalog.
6
+ * the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
7
+ * a tool to Cursor only when it is in that catalog.
8
+ *
9
+ * That AI SDK placement is necessary but not sufficient for Cursor models:
10
+ * non-native tools are still invoked through GetDynamicTools / CallDynamicTool
11
+ * (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
12
+ * puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
8
13
  *
9
14
  * `config.codemode` is also the remote-connection switch: while it is not
10
15
  * false, OpenCode appends `?codemode=false` so servers that bundle their own
@@ -2,8 +2,13 @@
2
2
  * OpenCode 2 puts an MCP server's tools in Code Mode unless that server's
3
3
  * config sets `codemode: false` (`packages/schema/src/mcp.ts`, applied in
4
4
  * `packages/core/src/tool/mcp.ts`). Only `options.codemode === false` joins
5
- * the AI SDK catalog (`packages/core/src/tool.ts`). Cursor can call a tool
6
- * only when it is in that catalog.
5
+ * the AI SDK catalog (`packages/core/src/tool.ts`). This provider can advertise
6
+ * a tool to Cursor only when it is in that catalog.
7
+ *
8
+ * That AI SDK placement is necessary but not sufficient for Cursor models:
9
+ * non-native tools are still invoked through GetDynamicTools / CallDynamicTool
10
+ * (see `src/context/dynamic-catalog.ts`, issue #29). Classic OpenCode 1.x already
11
+ * puts MCP tools in the AI SDK catalog; this module is the OpenCode 2 equivalent.
7
12
  *
8
13
  * `config.codemode` is also the remote-connection switch: while it is not
9
14
  * false, OpenCode appends `?codemode=false` so servers that bundle their own
@@ -17,6 +17,13 @@ export type Registration = {
17
17
  readonly dispose: () => Promise<void>;
18
18
  };
19
19
  export type Hooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void) => Promise<Registration>;
20
+ export type ModelHookOptions = {
21
+ /** Limits the hook to one provider; the host skips events for any other. */
22
+ readonly providerID?: string;
23
+ };
24
+ export type ModelHooks<Spec> = <Name extends keyof Spec>(name: Name, callback: (input: Spec[Name]) => Promise<void> | void, options?: Spec[Name] extends {
25
+ readonly model: unknown;
26
+ } ? ModelHookOptions : never) => Promise<Registration>;
20
27
  export type Transform<Input> = (callback: (input: Input) => void) => Promise<Registration>;
21
28
  export type ProviderInfo = {
22
29
  id: string;
@@ -304,11 +311,28 @@ export type SessionTitle = {
304
311
  options?: Record<string, unknown>;
305
312
  result?: string;
306
313
  };
314
+ /**
315
+ * Outbound request headers / route overrides for one model call.
316
+ * Headers reach the AI SDK as `callOptions.headers`.
317
+ */
318
+ export type SessionModelRequest = {
319
+ readonly sessionID: string;
320
+ readonly agent: string;
321
+ readonly model: {
322
+ providerID: string;
323
+ id: string;
324
+ variant?: string;
325
+ };
326
+ readonly kind: "primary" | "compaction" | "title" | "generate";
327
+ baseURL?: string;
328
+ headers: Record<string, string>;
329
+ };
307
330
  export type SessionHooks = {
308
331
  readonly context: SessionContext;
309
332
  readonly compaction: SessionCompaction;
310
333
  readonly generate: SessionGenerate;
311
334
  readonly title: SessionTitle;
335
+ readonly "model.request": SessionModelRequest;
312
336
  };
313
337
  /** Only the `location.directory` field we actually read. */
314
338
  export type SessionInfo = {
@@ -318,7 +342,7 @@ export type SessionInfo = {
318
342
  };
319
343
  };
320
344
  export type SessionDomain = {
321
- readonly hook: Hooks<SessionHooks>;
345
+ readonly hook: ModelHooks<SessionHooks>;
322
346
  readonly get: (input: {
323
347
  sessionID: string;
324
348
  }) => Promise<SessionInfo>;
@@ -13,7 +13,8 @@ import { registerTodoTools } from "./opencode2/todo-tools.js";
13
13
  import { OPENCODE_2_TOOL_DIALECT } from "./protocol/tools.js";
14
14
  import { clearSessionTodos } from "./todo-store.js";
15
15
  import { markCompactionSession } from "./compaction-marker.js";
16
- import { markSessionDirectory } from "./session-directory.js";
16
+ import { getSessionDirectory, markSessionDirectory } from "./session-directory.js";
17
+ import { trace } from "./debug.js";
17
18
  import { cancelPlanExecutionKickoff, createPlanExecutionKickoffText, setPlanExecutionKickoff, } from "./plan-execution-kickoff.js";
18
19
  import { cancelHostAgentModeSwitch, setHostAgentModeSwitch, } from "./host-agent-mode.js";
19
20
  import { clearActiveCursorMode, getActiveCursorMode, normalizeSwitchModeId, setActiveCursorMode, } from "./protocol/switch-mode.js";
@@ -215,8 +216,8 @@ const plugin = {
215
216
  ...(token ? { accessToken: token } : {}),
216
217
  // Static fallback only. This hook fires once per model/package, not
217
218
  // per session, and 2.0 runs one daemon across many projects — the
218
- // real per-request directory comes from `x-opencode-directory` and
219
- // the session.context hook below via `getSessionDirectory`.
219
+ // real per-request directory comes from `x-opencode-directory`
220
+ // (set on `session.model.request`) and the session-directory mark.
220
221
  workspaceRoot,
221
222
  cacheDir,
222
223
  ...event.options,
@@ -267,8 +268,9 @@ const plugin = {
267
268
  // the host does not already own those names. When off, register none.
268
269
  registerTodoTools(draft);
269
270
  // MCP tools default into Code Mode. Move every server that did not
270
- // explicitly opt in onto the direct catalog so Cursor can call them
271
- // by name. See `opencode2/mcp-direct.ts`.
271
+ // explicitly opt in onto the direct catalog so this provider can
272
+ // advertise them to Cursor (issue #29 still routes via CallDynamicTool).
273
+ // See `opencode2/mcp-direct.ts`.
272
274
  exposeDirectMcpTools(draft, directMcpNamespaces);
273
275
  }));
274
276
  // ── Shell timeout wrapper ────────────────────────────────
@@ -342,13 +344,12 @@ const plugin = {
342
344
  }
343
345
  const rememberSessionDirectory = async (sessionID) => {
344
346
  try {
345
- const info = (await ctx.session.get({ sessionID }));
346
- // OpenCode 2.0 stable exposes a flat `directory`; older shapes nest it
347
- // under `location.directory`. Prefer the flat field when both exist.
348
- markSessionDirectory(sessionID, info.directory ?? info.location?.directory);
347
+ const info = await ctx.session.get({ sessionID });
348
+ markSessionDirectory(sessionID, info.location.directory);
349
349
  }
350
- catch {
350
+ catch (error) {
351
351
  // Best effort — falls back to the static workspaceRoot above.
352
+ trace(`session directory: session.get failed sessionID=${sessionID}: ${String(error)}`);
352
353
  }
353
354
  };
354
355
  await track(ctx.session.hook("context", async (event) => {
@@ -371,6 +372,31 @@ const plugin = {
371
372
  }
372
373
  await rememberSessionDirectory(event.sessionID);
373
374
  }));
375
+ // The session mark lives in module state, and OpenCode re-evaluates a local
376
+ // plugin's module graph per Location, so the copy running the model may not
377
+ // hold it. The header travels with the request (AI SDK
378
+ // `callOptions.headers` → `resolveSessionWorkspaceRoot`) and never reaches
379
+ // Cursor. Only a successful lookup updates the mark; on failure keep the
380
+ // last known session directory ahead of this Location's static root.
381
+ // Scoped by the host to this provider: other providers' requests never
382
+ // reach the callback, so the header cannot leak to their endpoints.
383
+ await track(ctx.session.hook("model.request", async (event) => {
384
+ const current = await ctx.session
385
+ .get({ sessionID: event.sessionID })
386
+ .then((info) => info.location.directory)
387
+ .catch((error) => {
388
+ trace(`model.request: session.get failed sessionID=${event.sessionID}: ${String(error)}`);
389
+ return undefined;
390
+ });
391
+ markSessionDirectory(event.sessionID, current);
392
+ const directory = current ?? getSessionDirectory(event.sessionID) ?? ctx.location?.directory;
393
+ if (!directory)
394
+ return;
395
+ event.headers = {
396
+ ...event.headers,
397
+ "x-opencode-directory": encodeURIComponent(directory),
398
+ };
399
+ }, { providerID: CURSOR_PROVIDER_ID }));
374
400
  await track(ctx.session.hook("compaction", async (event) => {
375
401
  markCompactionAndOptions(event, true);
376
402
  await rememberSessionDirectory(event.sessionID);
@@ -91,6 +91,12 @@ export declare const CURSOR_MODEL_COSTS: {
91
91
  readonly cache_read: 0.2;
92
92
  readonly cache_write: 2.5;
93
93
  };
94
+ readonly "claude-sonnet-5-5": {
95
+ readonly input: 2;
96
+ readonly output: 10;
97
+ readonly cache_read: 0.2;
98
+ readonly cache_write: 2.5;
99
+ };
94
100
  readonly "composer-2.5": {
95
101
  readonly input: 0.5;
96
102
  readonly output: 2.5;
@@ -141,6 +147,16 @@ export declare const CURSOR_MODEL_COSTS: {
141
147
  readonly output: 4.4;
142
148
  readonly cache_read: 0.26;
143
149
  };
150
+ readonly "glm-5.3": {
151
+ readonly input: 1.4;
152
+ readonly output: 4.4;
153
+ readonly cache_read: 0.26;
154
+ };
155
+ readonly "glm-5.3-flash": {
156
+ readonly input: 0.15;
157
+ readonly output: 0.5;
158
+ readonly cache_read: 0.029;
159
+ };
144
160
  readonly "gpt-5-mini": {
145
161
  readonly input: 0.25;
146
162
  readonly output: 2;
@@ -335,6 +351,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
335
351
  readonly maxContext: 200000;
336
352
  readonly maxContextForMaxMode: 1000000;
337
353
  };
354
+ readonly "claude-sonnet-5-5": {
355
+ readonly maxContext: 200000;
356
+ readonly maxContextForMaxMode: 1000000;
357
+ };
338
358
  readonly "composer-2.5": {
339
359
  readonly maxContext: 200000;
340
360
  };
@@ -369,6 +389,12 @@ export declare const CURSOR_MODEL_CONTEXTS: {
369
389
  readonly "glm-5.2": {
370
390
  readonly maxContext: 200000;
371
391
  };
392
+ readonly "glm-5.3": {
393
+ readonly maxContext: 1000000;
394
+ };
395
+ readonly "glm-5.3-flash": {
396
+ readonly maxContext: 1000000;
397
+ };
372
398
  readonly "gpt-5-mini": {
373
399
  readonly maxContext: 272000;
374
400
  };
@@ -469,6 +495,9 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
469
495
  readonly "claude-sonnet-5": {
470
496
  readonly supportsImages: true;
471
497
  };
498
+ readonly "claude-sonnet-5-5": {
499
+ readonly supportsImages: true;
500
+ };
472
501
  readonly "composer-2.5": {
473
502
  readonly supportsImages: true;
474
503
  };
@@ -496,6 +525,12 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
496
525
  readonly "glm-5.2": {
497
526
  readonly supportsImages: false;
498
527
  };
528
+ readonly "glm-5.3": {
529
+ readonly supportsImages: false;
530
+ };
531
+ readonly "glm-5.3-flash": {
532
+ readonly supportsImages: true;
533
+ };
499
534
  readonly "gpt-5-mini": {
500
535
  readonly supportsImages: true;
501
536
  };
@@ -91,6 +91,12 @@ export const CURSOR_MODEL_COSTS = {
91
91
  "cache_read": 0.2,
92
92
  "cache_write": 2.5
93
93
  },
94
+ "claude-sonnet-5-5": {
95
+ "input": 2,
96
+ "output": 10,
97
+ "cache_read": 0.2,
98
+ "cache_write": 2.5
99
+ },
94
100
  "composer-2.5": {
95
101
  "input": 0.5,
96
102
  "output": 2.5,
@@ -141,6 +147,16 @@ export const CURSOR_MODEL_COSTS = {
141
147
  "output": 4.4,
142
148
  "cache_read": 0.26
143
149
  },
150
+ "glm-5.3": {
151
+ "input": 1.4,
152
+ "output": 4.4,
153
+ "cache_read": 0.26
154
+ },
155
+ "glm-5.3-flash": {
156
+ "input": 0.15,
157
+ "output": 0.5,
158
+ "cache_read": 0.029
159
+ },
144
160
  "gpt-5-mini": {
145
161
  "input": 0.25,
146
162
  "output": 2,
@@ -335,6 +351,10 @@ export const CURSOR_MODEL_CONTEXTS = {
335
351
  "maxContext": 200000,
336
352
  "maxContextForMaxMode": 1000000
337
353
  },
354
+ "claude-sonnet-5-5": {
355
+ "maxContext": 200000,
356
+ "maxContextForMaxMode": 1000000
357
+ },
338
358
  "composer-2.5": {
339
359
  "maxContext": 200000
340
360
  },
@@ -369,6 +389,12 @@ export const CURSOR_MODEL_CONTEXTS = {
369
389
  "glm-5.2": {
370
390
  "maxContext": 200000
371
391
  },
392
+ "glm-5.3": {
393
+ "maxContext": 1000000
394
+ },
395
+ "glm-5.3-flash": {
396
+ "maxContext": 1000000
397
+ },
372
398
  "gpt-5-mini": {
373
399
  "maxContext": 272000
374
400
  },
@@ -469,6 +495,9 @@ export const CURSOR_MODEL_CAPABILITIES = {
469
495
  "claude-sonnet-5": {
470
496
  "supportsImages": true
471
497
  },
498
+ "claude-sonnet-5-5": {
499
+ "supportsImages": true
500
+ },
472
501
  "composer-2.5": {
473
502
  "supportsImages": true
474
503
  },
@@ -496,6 +525,12 @@ export const CURSOR_MODEL_CAPABILITIES = {
496
525
  "glm-5.2": {
497
526
  "supportsImages": false
498
527
  },
528
+ "glm-5.3": {
529
+ "supportsImages": false
530
+ },
531
+ "glm-5.3-flash": {
532
+ "supportsImages": true
533
+ },
499
534
  "gpt-5-mini": {
500
535
  "supportsImages": true
501
536
  },
@@ -30,8 +30,8 @@ export type DecodedGenerateImageQuery = {
30
30
  referenceImagePaths: string[];
31
31
  toolCallId: string;
32
32
  };
33
- /** Cursor names the target file; its extension is the only mime signal sent. */
34
- export declare function imageMimeForPath(filePath: string): string;
33
+ /** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
34
+ export declare function imageMimeForPath(filePath: string, data?: Uint8Array): string;
35
35
  /** Decode a `generate_image_request_query` body, or undefined when unusable. */
36
36
  export declare function decodeGenerateImageQuery(queryBytes: Uint8Array): DecodedGenerateImageQuery | undefined;
37
37
  export type ImageWriteRoots = {
@@ -32,8 +32,20 @@ const IMAGE_MIME_BY_EXTENSION = {
32
32
  ".gif": "image/gif",
33
33
  ".webp": "image/webp",
34
34
  };
35
- /** Cursor names the target file; its extension is the only mime signal sent. */
36
- export function imageMimeForPath(filePath) {
35
+ /** Prefer encoded bytes: Cursor can send JPEG data with a .png target name. */
36
+ export function imageMimeForPath(filePath, data) {
37
+ if (data) {
38
+ const matches = (signature, offset = 0) => signature.every((byte, index) => data[offset + index] === byte);
39
+ if (matches([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
40
+ return "image/png";
41
+ if (matches([0xff, 0xd8, 0xff]))
42
+ return "image/jpeg";
43
+ if (matches([0x47, 0x49, 0x46, 0x38]) && (data[4] === 0x37 || data[4] === 0x39) && data[5] === 0x61) {
44
+ return "image/gif";
45
+ }
46
+ if (matches([0x52, 0x49, 0x46, 0x46]) && matches([0x57, 0x45, 0x42, 0x50], 8))
47
+ return "image/webp";
48
+ }
37
49
  const dot = path.basename(filePath).lastIndexOf(".");
38
50
  if (dot <= 0)
39
51
  return "image/png";
@@ -12,9 +12,12 @@ export type OpencodeToolDef = {
12
12
  export type HostFilePathKey = "path" | "filePath";
13
13
  /** Host shell tool id. OpenCode 1.x uses `bash`; 2.0 uses `shell`. */
14
14
  export type HostShellTool = "bash" | "shell";
15
+ /** OpenCode 1.x skill params use `name`; OpenCode 2.0 uses `id`. */
16
+ export type HostSkillArgKey = "name" | "id";
15
17
  export type HostToolDialect = {
16
18
  filePathKey: HostFilePathKey;
17
19
  shellTool: HostShellTool;
20
+ skillArgKey: HostSkillArgKey;
18
21
  };
19
22
  export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
20
23
  export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
@@ -23,6 +26,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
23
26
  /**
24
27
  * Infer the host tool dialect from advertised AI SDK schemas.
25
28
  * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
29
+ * Skill: OpenCode 1.x requires `name`; OpenCode 2.0 requires `id`.
26
30
  */
27
31
  export declare function hostToolDialectFromTools(tools: readonly {
28
32
  name?: string;