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.
- package/README.md +2 -0
- package/dist/context/build.d.ts +6 -0
- package/dist/context/build.js +4 -2
- package/dist/context/dynamic-catalog.d.ts +74 -0
- package/dist/context/dynamic-catalog.js +228 -0
- package/dist/context/frozen.js +5 -0
- package/dist/context/index.d.ts +1 -0
- package/dist/context/index.js +1 -0
- package/dist/context/overlay.d.ts +5 -0
- package/dist/context/overlay.js +7 -0
- package/dist/context/rules.d.ts +2 -1
- package/dist/context/rules.js +3 -2
- package/dist/image-save.d.ts +1 -1
- package/dist/image-save.js +3 -3
- package/dist/image-staging.js +6 -1
- package/dist/language-model.d.ts +23 -1
- package/dist/language-model.js +140 -22
- package/dist/opencode2/mcp-direct.d.ts +7 -2
- package/dist/opencode2/mcp-direct.js +7 -2
- package/dist/opencode2/types.d.ts +25 -1
- package/dist/plugin-opencode2.js +36 -10
- package/dist/pricing-data.d.ts +35 -0
- package/dist/pricing-data.js +35 -0
- package/dist/protocol/generate-image.d.ts +2 -2
- package/dist/protocol/generate-image.js +14 -2
- package/dist/protocol/tools.d.ts +4 -0
- package/dist/protocol/tools.js +30 -1
- package/dist/session-directory.d.ts +6 -3
- package/dist/session-directory.js +13 -6
- package/package.json +1 -1
package/dist/language-model.js
CHANGED
|
@@ -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
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
|
805
|
-
//
|
|
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
|
-
|
|
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:
|
|
863
|
+
ephemeral: ephemeralRun,
|
|
829
864
|
});
|
|
830
|
-
let conversationState =
|
|
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:
|
|
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(
|
|
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`).
|
|
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`).
|
|
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:
|
|
345
|
+
readonly hook: ModelHooks<SessionHooks>;
|
|
322
346
|
readonly get: (input: {
|
|
323
347
|
sessionID: string;
|
|
324
348
|
}) => Promise<SessionInfo>;
|
package/dist/plugin-opencode2.js
CHANGED
|
@@ -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`
|
|
219
|
-
//
|
|
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
|
|
271
|
-
//
|
|
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 =
|
|
346
|
-
|
|
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);
|
package/dist/pricing-data.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/pricing-data.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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";
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -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;
|