cursor-opencode-provider 0.5.0 → 0.6.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.
- package/README.md +18 -13
- package/dist/context/agents.d.ts +1 -1
- package/dist/context/agents.js +1 -1
- package/dist/context/auth-store.js +2 -2
- package/dist/context/build.d.ts +3 -1
- package/dist/context/build.js +4 -2
- package/dist/context/index.d.ts +1 -1
- package/dist/context/index.js +1 -1
- package/dist/context/paths.d.ts +34 -53
- package/dist/context/paths.js +54 -118
- package/dist/context/rules.js +1 -1
- package/dist/image-save-tool.d.ts +19 -0
- package/dist/image-save-tool.js +25 -0
- package/dist/image-save.d.ts +58 -0
- package/dist/image-save.js +189 -0
- package/dist/image-staging.d.ts +59 -0
- package/dist/image-staging.js +91 -0
- package/dist/index.d.ts +1 -1
- package/dist/language-model.d.ts +3 -2
- package/dist/language-model.js +572 -34
- package/dist/plan-execution-kickoff.d.ts +55 -0
- package/dist/plan-execution-kickoff.js +162 -0
- package/dist/plugin-opencode2.js +52 -3
- package/dist/plugin-v2.d.ts +11 -9
- package/dist/plugin-v2.js +6 -13
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.js +61 -5
- package/dist/pricing-data.d.ts +16 -4
- package/dist/pricing-data.js +16 -4
- package/dist/protocol/ask-question.d.ts +133 -0
- package/dist/protocol/ask-question.js +320 -0
- package/dist/protocol/create-plan.d.ts +141 -0
- package/dist/protocol/create-plan.js +373 -0
- package/dist/protocol/generate-image.d.ts +54 -0
- package/dist/protocol/generate-image.js +101 -0
- package/dist/protocol/interactions.d.ts +110 -8
- package/dist/protocol/interactions.js +295 -35
- package/dist/protocol/messages.js +118 -6
- package/dist/protocol/switch-mode.d.ts +178 -0
- package/dist/protocol/switch-mode.js +417 -0
- package/dist/protocol/tool-call-bridge.js +10 -5
- package/dist/protocol/tools.d.ts +27 -11
- package/dist/protocol/tools.js +95 -41
- package/dist/usage.d.ts +2 -2
- package/dist/usage.js +2 -2
- package/dist/web-search-tool.d.ts +22 -26
- package/dist/web-search-tool.js +25 -15
- package/package.json +7 -4
package/dist/language-model.js
CHANGED
|
@@ -6,11 +6,18 @@ import { trace, traceRequestContextPaths } from "./debug.js";
|
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
8
|
import { debugWalkTurnEnded, decodeMessage, encodeMessage } from "./protocol/messages.js";
|
|
9
|
-
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, isUriReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildListMcpResourcesFallback, buildReadMcpResourceFallback, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, remapCorrelatedEditWriteForCatalog, remapEditToolsForCatalog, buildCompleteEditReadMessages, rejectPartialReadMutation, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_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";
|
|
13
|
-
import { handleInteractionQuery } from "./protocol/interactions.js";
|
|
13
|
+
import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
|
|
14
|
+
import { ASK_QUESTION_RESULT_FIELD, askQuestionResultFromToolOutput, askQuestionToolInput, } from "./protocol/ask-question.js";
|
|
15
|
+
import { SWITCH_MODE_RESULT_FIELD, getActiveCursorMode, isBridgedCursorPlanModeActive, isCursorPlanModeActive, setActiveCursorMode, switchModeResultFromQuestionOutput, switchModeResultFromToolOutput, switchModeToolInput, takeActiveCursorModeReminder, } from "./protocol/switch-mode.js";
|
|
16
|
+
import { CREATE_PLAN_NOT_APPROVED_REASON, CREATE_PLAN_RESULT_FIELD, CURSOR_PLAN_STAGE_TOOL, createPlanApproved, createPlanStageInput, } from "./protocol/create-plan.js";
|
|
17
|
+
import { flushPlanExecutionKickoff, formatPlanKickoffPath, hasPlanExecutionKickoff, planExecutionKickoffState, planPathFromUri, queuePlanExecutionKickoff, takePlanExecutionKickoffWarning, } from "./plan-execution-kickoff.js";
|
|
18
|
+
import { CURSOR_IMAGE_SAVE_TOOL, imageMimeForPath, remapCursorImageWritePath, } from "./protocol/generate-image.js";
|
|
19
|
+
import { stageCursorImage } from "./image-staging.js";
|
|
20
|
+
import { IMAGE_PERMISSION_DENIED_PREFIX } from "./image-save.js";
|
|
14
21
|
import { clearCheckpoint, getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
15
22
|
import { cursorContextUsageMetadata, decodeConversationTokenDetails, } from "./protocol/token-details.js";
|
|
16
23
|
import { clearConversationBlobs, conversationBlobCount, inspectConversationBlobGraph, } from "./protocol/blob-store.js";
|
|
@@ -22,7 +29,7 @@ import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, Cur
|
|
|
22
29
|
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
23
30
|
import { getOrBuildRequestContext } from "./context/frozen.js";
|
|
24
31
|
import { workspaceRootFromRequestContext } from "./context/env.js";
|
|
25
|
-
import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
|
|
32
|
+
import { ensureOpencodeProjectDir, opencodeGlobalCacheDir, setHostCacheDirOverride, } from "./context/paths.js";
|
|
26
33
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
27
34
|
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
28
35
|
import { isCompactionSession } from "./compaction-marker.js";
|
|
@@ -243,9 +250,61 @@ export function connectFrameError(payload) {
|
|
|
243
250
|
return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
|
|
244
251
|
}
|
|
245
252
|
}
|
|
253
|
+
const toolCatalogWaitersBySession = new Map();
|
|
254
|
+
function publishToolCatalogWaiters(sessionKey, tools) {
|
|
255
|
+
const waiters = toolCatalogWaitersBySession.get(sessionKey);
|
|
256
|
+
if (!waiters || waiters.size === 0)
|
|
257
|
+
return;
|
|
258
|
+
toolCatalogWaitersBySession.delete(sessionKey);
|
|
259
|
+
for (const waiter of waiters)
|
|
260
|
+
waiter.resolve(tools);
|
|
261
|
+
}
|
|
262
|
+
function waitForSiblingToolCatalog(sessionKey, signal) {
|
|
263
|
+
const existing = toolCatalogBySession.get(sessionKey);
|
|
264
|
+
if (existing && existing.length > 0) {
|
|
265
|
+
return Promise.resolve(structuredClone(existing));
|
|
266
|
+
}
|
|
267
|
+
if (signal?.aborted) {
|
|
268
|
+
return Promise.reject(new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
|
|
269
|
+
}
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
let settled = false;
|
|
272
|
+
let set = toolCatalogWaitersBySession.get(sessionKey);
|
|
273
|
+
const finish = (tools, error) => {
|
|
274
|
+
if (settled)
|
|
275
|
+
return;
|
|
276
|
+
settled = true;
|
|
277
|
+
set?.delete(waiter);
|
|
278
|
+
if (set?.size === 0)
|
|
279
|
+
toolCatalogWaitersBySession.delete(sessionKey);
|
|
280
|
+
signal?.removeEventListener("abort", onAbort);
|
|
281
|
+
if (error)
|
|
282
|
+
reject(error);
|
|
283
|
+
else
|
|
284
|
+
resolve(structuredClone(tools));
|
|
285
|
+
};
|
|
286
|
+
const onAbort = () => finish(undefined, new CursorLocalCancellationError("Cursor tool-catalog wait cancelled"));
|
|
287
|
+
const waiter = {
|
|
288
|
+
resolve: (tools) => finish(tools),
|
|
289
|
+
cancel: onAbort,
|
|
290
|
+
};
|
|
291
|
+
if (!set) {
|
|
292
|
+
set = new Set();
|
|
293
|
+
toolCatalogWaitersBySession.set(sessionKey, set);
|
|
294
|
+
}
|
|
295
|
+
set.add(waiter);
|
|
296
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
297
|
+
// Re-check after enqueue: a sibling may have published between the initial
|
|
298
|
+
// map lookup and waiter registration.
|
|
299
|
+
const raced = toolCatalogBySession.get(sessionKey);
|
|
300
|
+
if (raced && raced.length > 0)
|
|
301
|
+
finish(raced);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
246
304
|
function rememberToolCatalog(sessionKey, tools) {
|
|
247
305
|
toolCatalogBySession.delete(sessionKey);
|
|
248
306
|
toolCatalogBySession.set(sessionKey, structuredClone(tools));
|
|
307
|
+
publishToolCatalogWaiters(sessionKey, tools);
|
|
249
308
|
while (toolCatalogBySession.size > MAX_TURN_STATE_SESSIONS) {
|
|
250
309
|
const oldest = toolCatalogBySession.keys().next().value;
|
|
251
310
|
if (!oldest)
|
|
@@ -422,6 +481,23 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
422
481
|
catch (e) {
|
|
423
482
|
trace(`pull: close failed (already closed/cancelled) err=${e.message}`);
|
|
424
483
|
}
|
|
484
|
+
// pumpWithRecovery has returned only after the Run reached terminal
|
|
485
|
+
// turn_ended and endPump cleared ownership. This is the first safe
|
|
486
|
+
// point to start a second OpenCode generation. A failed prior handoff
|
|
487
|
+
// is retried only because this explicit new provider turn reached its
|
|
488
|
+
// own terminal boundary — never from a timer or tight loop.
|
|
489
|
+
const kickoff = await flushPlanExecutionKickoff(activeSession.openCodeSessionId, {
|
|
490
|
+
cursorSessionID: activeSession.sessionId,
|
|
491
|
+
terminal: activeSession.closed,
|
|
492
|
+
pumpActive: activeSession.pumpActive || activeSession.pumpOwner != null,
|
|
493
|
+
pendingExecs: activeSession.pending.size,
|
|
494
|
+
});
|
|
495
|
+
if (!kickoff) {
|
|
496
|
+
const state = planExecutionKickoffState(activeSession.openCodeSessionId);
|
|
497
|
+
if (state?.status === "failed") {
|
|
498
|
+
setActiveCursorMode(activeSession.openCodeSessionId, "plan");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
425
501
|
}
|
|
426
502
|
catch (e) {
|
|
427
503
|
activeSession.pumpActive = false;
|
|
@@ -524,11 +600,12 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
524
600
|
// the session id from session.hook("context") instead; consult both.
|
|
525
601
|
// Do not infer this from tools/toolChoice: standalone no-tool calls are valid.
|
|
526
602
|
const isCompaction = providerOptions?.[CURSOR_COMPACTION_OPTION] === true || isCompactionSession(sessionKey);
|
|
527
|
-
const toolState = resolveTurnToolState({
|
|
603
|
+
const toolState = await resolveTurnToolState({
|
|
528
604
|
sessionKey,
|
|
529
605
|
incomingTools,
|
|
530
606
|
toolChoice: callOptions.toolChoice,
|
|
531
607
|
isCompaction,
|
|
608
|
+
abortSignal: callOptions.abortSignal,
|
|
532
609
|
});
|
|
533
610
|
const tools = toolState.advertisedTools;
|
|
534
611
|
const webToolAliases = buildCustomWebToolAliases(tools);
|
|
@@ -606,9 +683,24 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
606
683
|
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
607
684
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
608
685
|
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
686
|
+
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
687
|
+
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
688
|
+
const modeReminder = isCompaction
|
|
689
|
+
? undefined
|
|
690
|
+
: takeActiveCursorModeReminder(sessionKey, {
|
|
691
|
+
advertisedTools: cursorTools.map((tool) => tool.name),
|
|
692
|
+
});
|
|
693
|
+
const kickoffWarning = isCompaction
|
|
694
|
+
? undefined
|
|
695
|
+
: takePlanExecutionKickoffWarning(sessionKey);
|
|
696
|
+
const systemPrompt = [
|
|
697
|
+
baseSystemPrompt,
|
|
698
|
+
interactionGuidance,
|
|
699
|
+
modeReminder,
|
|
700
|
+
kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
|
|
701
|
+
]
|
|
702
|
+
.filter(Boolean)
|
|
703
|
+
.join("\n\n");
|
|
612
704
|
const history = extractPromptHistory(prompt, {
|
|
613
705
|
preserveTrailingUser: recovery?.kind === "rebase",
|
|
614
706
|
toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
@@ -886,6 +978,124 @@ export function findContinuationSession(toolResults) {
|
|
|
886
978
|
}
|
|
887
979
|
return undefined;
|
|
888
980
|
}
|
|
981
|
+
/**
|
|
982
|
+
* Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
|
|
983
|
+
*
|
|
984
|
+
* A synchronous AskQuestion still has its InteractionQuery open, so the answer
|
|
985
|
+
* goes back as the InteractionResponse for that query id. A `run_async` one was
|
|
986
|
+
* already released with `async{}`, so — exactly as Cursor CLI does — the answer
|
|
987
|
+
* travels as a ConversationAction keyed by the originating tool call id, with
|
|
988
|
+
* the server's own AskQuestionArgs bytes echoed back untouched.
|
|
989
|
+
*/
|
|
990
|
+
function buildAskQuestionContinuationFrame(pending, result) {
|
|
991
|
+
const metadata = pending.resultMetadata ?? {};
|
|
992
|
+
const args = metadata.askQuestionArgs;
|
|
993
|
+
if (!args)
|
|
994
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
|
|
995
|
+
const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
|
|
996
|
+
if (!args.runAsync) {
|
|
997
|
+
const interactionId = metadata.interactionId;
|
|
998
|
+
if (typeof interactionId !== "number") {
|
|
999
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
|
|
1000
|
+
}
|
|
1001
|
+
return buildAskQuestionInteractionReply(interactionId, answer);
|
|
1002
|
+
}
|
|
1003
|
+
const rawArgs = metadata.askQuestionRawArgs;
|
|
1004
|
+
if (!(rawArgs instanceof Uint8Array)) {
|
|
1005
|
+
throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
|
|
1006
|
+
}
|
|
1007
|
+
const toolCallId = metadata.askQuestionToolCallId;
|
|
1008
|
+
return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
|
|
1012
|
+
* still blocking on. One contract across every channel: `success` means the
|
|
1013
|
+
* user approved execution, `error` means the plan was written but not accepted,
|
|
1014
|
+
* so the model keeps planning instead of starting work.
|
|
1015
|
+
*
|
|
1016
|
+
* A host plan-stage tool reports that outcome as tool success/failure. The
|
|
1017
|
+
* emulated path asks through `question`, so only an explicit "Yes" approves.
|
|
1018
|
+
* On approval the session leaves plan mode, which arms the agent-mode
|
|
1019
|
+
* `<system_reminder>` for the next Run.
|
|
1020
|
+
*/
|
|
1021
|
+
function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
|
|
1022
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1023
|
+
const interactionId = metadata.interactionId;
|
|
1024
|
+
if (typeof interactionId !== "number") {
|
|
1025
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
|
|
1026
|
+
}
|
|
1027
|
+
const planUri = metadata.planUri;
|
|
1028
|
+
if (typeof planUri !== "string" || !planUri.trim()) {
|
|
1029
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
|
|
1030
|
+
}
|
|
1031
|
+
const approved = metadata.createPlanBridgeKind === "approve"
|
|
1032
|
+
? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
|
|
1033
|
+
: result.error === undefined;
|
|
1034
|
+
if (!approved) {
|
|
1035
|
+
const reason = result.error?.trim();
|
|
1036
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1037
|
+
error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
|
|
1038
|
+
plan_uri: "",
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
// The question-emulated path can report execution approval only when a host
|
|
1042
|
+
// kickoff exists. Otherwise keep planning mode active and return an error.
|
|
1043
|
+
if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
|
|
1044
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1045
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1046
|
+
plan_uri: planUri,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
// Approved: planning is over for this plan, so drop the plan-mode reminder
|
|
1050
|
+
// and hand the next Run the agent-mode contract, exactly as an approved
|
|
1051
|
+
// plan_exit does.
|
|
1052
|
+
setActiveCursorMode(sessionKey, "agent");
|
|
1053
|
+
// Only the question-emulated path needs the provider's synthetic build turn.
|
|
1054
|
+
// A host stage tool owns both approval and execution handoff itself.
|
|
1055
|
+
if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
|
|
1056
|
+
const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
|
|
1057
|
+
? metadata.planPath.trim()
|
|
1058
|
+
: planPathFromUri(planUri);
|
|
1059
|
+
const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
|
|
1060
|
+
if (!queuePlanExecutionKickoff({
|
|
1061
|
+
sessionID: sessionKey,
|
|
1062
|
+
planPath: formatPlanKickoffPath(absolute, workspaceRoot),
|
|
1063
|
+
cursorSessionID,
|
|
1064
|
+
})) {
|
|
1065
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1066
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1067
|
+
plan_uri: planUri,
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1072
|
+
success: {},
|
|
1073
|
+
plan_uri: planUri,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
|
|
1077
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1078
|
+
const interactionId = metadata.interactionId;
|
|
1079
|
+
if (typeof interactionId !== "number") {
|
|
1080
|
+
throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
|
|
1081
|
+
}
|
|
1082
|
+
// A question-emulated exit carries the user's Yes/No as prose, not a plan
|
|
1083
|
+
// tool's success/failure, so it needs the answer parser rather than the
|
|
1084
|
+
// error-shaped mapping.
|
|
1085
|
+
const answer = metadata.switchModeBridgeKind === "question"
|
|
1086
|
+
? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
|
|
1087
|
+
: switchModeResultFromToolOutput(result.output, result.error !== undefined);
|
|
1088
|
+
if ("approved" in answer) {
|
|
1089
|
+
const target = metadata.switchModeTarget;
|
|
1090
|
+
if (typeof target === "string" && target.trim()) {
|
|
1091
|
+
const normalized = target.trim().toLowerCase();
|
|
1092
|
+
setActiveCursorMode(sessionKey, target, {
|
|
1093
|
+
bridgedPlanEntered: normalized === "plan" || normalized === "spec",
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return buildSwitchModeInteractionReply(interactionId, answer);
|
|
1098
|
+
}
|
|
889
1099
|
/**
|
|
890
1100
|
* Deliver trailing tool results onto a live continuation session.
|
|
891
1101
|
* Returns the same session when writes succeed (or only bridged results were
|
|
@@ -912,7 +1122,44 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
912
1122
|
}
|
|
913
1123
|
const pending = claim.pending;
|
|
914
1124
|
let frames = [];
|
|
915
|
-
if (
|
|
1125
|
+
if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
|
|
1126
|
+
// A bridged Cursor AskQuestion. The host tool result carries the user's
|
|
1127
|
+
// choices; translate them back into the Cursor result the interaction is
|
|
1128
|
+
// waiting for (or into the deferred completion, when Cursor was already
|
|
1129
|
+
// released with `async`).
|
|
1130
|
+
try {
|
|
1131
|
+
frames = [buildAskQuestionContinuationFrame(pending, r)];
|
|
1132
|
+
}
|
|
1133
|
+
catch (error) {
|
|
1134
|
+
trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1135
|
+
sessionManager.close(session, "result-write-failed");
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
|
|
1140
|
+
// Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
|
|
1141
|
+
// approved{} or rejected{reason} on the still-open InteractionQuery.
|
|
1142
|
+
// Approval also arms the CLI-shaped mode reminder for the next Run.
|
|
1143
|
+
try {
|
|
1144
|
+
frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1148
|
+
sessionManager.close(session, "result-write-failed");
|
|
1149
|
+
return undefined;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
|
|
1153
|
+
try {
|
|
1154
|
+
frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
|
|
1155
|
+
}
|
|
1156
|
+
catch (error) {
|
|
1157
|
+
trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1158
|
+
sessionManager.close(session, "result-write-failed");
|
|
1159
|
+
return undefined;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
else if (!pending.bridged) {
|
|
916
1163
|
try {
|
|
917
1164
|
const shellResult = pending.resultField === "shell_stream"
|
|
918
1165
|
|| pending.resultField === "background_shell_spawn_result"
|
|
@@ -939,6 +1186,55 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
939
1186
|
}
|
|
940
1187
|
}
|
|
941
1188
|
}
|
|
1189
|
+
// A refused image commit is Cursor's own `permission_denied` variant,
|
|
1190
|
+
// not a generic write error: its agent branches on that case. The
|
|
1191
|
+
// marker is set by this provider's own tool, so matching it is a
|
|
1192
|
+
// contract rather than a guess at someone else's message text.
|
|
1193
|
+
if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1194
|
+
&& pending.resultField === "write_result"
|
|
1195
|
+
&& r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
|
|
1196
|
+
const deniedPath = typeof pending.resultMetadata?.path === "string"
|
|
1197
|
+
? pending.resultMetadata.path
|
|
1198
|
+
: "";
|
|
1199
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1200
|
+
exec_client_message: {
|
|
1201
|
+
id: r.execId,
|
|
1202
|
+
write_result: {
|
|
1203
|
+
permission_denied: {
|
|
1204
|
+
path: deniedPath,
|
|
1205
|
+
directory: deniedPath ? path.dirname(deniedPath) : "",
|
|
1206
|
+
operation: "write",
|
|
1207
|
+
error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
|
|
1208
|
+
is_readonly: false,
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
})];
|
|
1213
|
+
}
|
|
1214
|
+
// A committed image reports the file Cursor asked about, not the tool's
|
|
1215
|
+
// prose. `buildExecClientMessages` would otherwise put the message's
|
|
1216
|
+
// character count in `WriteSuccess.file_size`.
|
|
1217
|
+
if (frames.length === 0
|
|
1218
|
+
&& !r.error
|
|
1219
|
+
&& pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1220
|
+
&& pending.resultField === "write_result") {
|
|
1221
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1222
|
+
exec_client_message: {
|
|
1223
|
+
id: r.execId,
|
|
1224
|
+
write_result: {
|
|
1225
|
+
success: {
|
|
1226
|
+
path: typeof pending.resultMetadata?.path === "string"
|
|
1227
|
+
? pending.resultMetadata.path
|
|
1228
|
+
: "",
|
|
1229
|
+
lines_created: 0,
|
|
1230
|
+
file_size: typeof pending.resultMetadata?.imageByteLength === "number"
|
|
1231
|
+
? pending.resultMetadata.imageByteLength
|
|
1232
|
+
: 0,
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
},
|
|
1236
|
+
})];
|
|
1237
|
+
}
|
|
942
1238
|
if (frames.length === 0) {
|
|
943
1239
|
frames = buildExecClientMessages({
|
|
944
1240
|
execId: r.execId,
|
|
@@ -970,7 +1266,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
970
1266
|
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
971
1267
|
continue;
|
|
972
1268
|
}
|
|
973
|
-
|
|
1269
|
+
const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
|
|
1270
|
+
? "ask_question answer"
|
|
1271
|
+
: pending.resultField === SWITCH_MODE_RESULT_FIELD
|
|
1272
|
+
? "switch_mode answer"
|
|
1273
|
+
: pending.resultField === CREATE_PLAN_RESULT_FIELD
|
|
1274
|
+
? "create_plan answer"
|
|
1275
|
+
: "exec result";
|
|
1276
|
+
trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
|
|
974
1277
|
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
975
1278
|
}
|
|
976
1279
|
return session;
|
|
@@ -1315,10 +1618,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1315
1618
|
const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
1316
1619
|
if (!requested)
|
|
1317
1620
|
return false;
|
|
1318
|
-
// A scheme-addressed target is the host's to resolve,
|
|
1319
|
-
// stat. Rejecting it here
|
|
1320
|
-
//
|
|
1321
|
-
// permanently unreachable through this provider.
|
|
1621
|
+
// A scheme-addressed target is the advertised host executor's to resolve,
|
|
1622
|
+
// not a local file to stat. Rejecting it here would make any URI-backed
|
|
1623
|
+
// capability permanently unreachable through this provider.
|
|
1322
1624
|
if (isUriReadTarget(requested)) {
|
|
1323
1625
|
trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
|
|
1324
1626
|
return false;
|
|
@@ -1811,6 +2113,63 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1811
2113
|
return;
|
|
1812
2114
|
continue;
|
|
1813
2115
|
}
|
|
2116
|
+
// Cursor writes a generated image with an ordinary write exec whose
|
|
2117
|
+
// `file_bytes` are binary (Cursor CLI's agent does exactly this, then
|
|
2118
|
+
// reads the client's WriteResult back). OpenCode's `write` takes a
|
|
2119
|
+
// string, so those bytes are staged and committed by the host tool
|
|
2120
|
+
// that can raise the `edit` permission before anything hits disk.
|
|
2121
|
+
const binaryWrite = binaryWritePayload(parsed);
|
|
2122
|
+
if (binaryWrite) {
|
|
2123
|
+
if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
|
|
2124
|
+
const reason = "This OpenCode agent cannot write binary file content. "
|
|
2125
|
+
+ "Do not retry this write with the same bytes.";
|
|
2126
|
+
if (!rejectExec(parsed, reason, "binary write unsupported"))
|
|
2127
|
+
return;
|
|
2128
|
+
continue;
|
|
2129
|
+
}
|
|
2130
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
2131
|
+
const projectDir = ensureOpencodeProjectDir(workspaceRoot);
|
|
2132
|
+
const target = remapCursorImageWritePath(binaryWrite.path, {
|
|
2133
|
+
workspaceRoot,
|
|
2134
|
+
projectDir,
|
|
2135
|
+
});
|
|
2136
|
+
let imageId;
|
|
2137
|
+
try {
|
|
2138
|
+
imageId = stageCursorImage({
|
|
2139
|
+
path: target,
|
|
2140
|
+
projectDir,
|
|
2141
|
+
mime: imageMimeForPath(target),
|
|
2142
|
+
data: binaryWrite.data,
|
|
2143
|
+
sessionId: session.openCodeSessionId,
|
|
2144
|
+
});
|
|
2145
|
+
}
|
|
2146
|
+
catch (error) {
|
|
2147
|
+
if (!rejectExec(parsed, error.message, "binary write too large"))
|
|
2148
|
+
return;
|
|
2149
|
+
continue;
|
|
2150
|
+
}
|
|
2151
|
+
if (displayCallId)
|
|
2152
|
+
session.displayToolCalls.delete(displayCallId);
|
|
2153
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
|
|
2154
|
+
...parsed.resultMetadata,
|
|
2155
|
+
path: target,
|
|
2156
|
+
binaryWriteBytes: undefined,
|
|
2157
|
+
imageByteLength: binaryWrite.data.length,
|
|
2158
|
+
});
|
|
2159
|
+
const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
|
|
2160
|
+
trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
|
|
2161
|
+
`requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
|
|
2162
|
+
`bytes=${binaryWrite.data.length}`);
|
|
2163
|
+
closeOpenSpans();
|
|
2164
|
+
safeEnqueue({
|
|
2165
|
+
type: "tool-call",
|
|
2166
|
+
toolCallId,
|
|
2167
|
+
toolName: CURSOR_IMAGE_SAVE_TOOL,
|
|
2168
|
+
input: JSON.stringify({ image_id: imageId }),
|
|
2169
|
+
});
|
|
2170
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
1814
2173
|
// Cursor has native capabilities (Task, filesystem, shell, etc.) in
|
|
1815
2174
|
// addition to the MCP descriptors sent by this provider. The model
|
|
1816
2175
|
// can request one even when the current OpenCode agent omitted its
|
|
@@ -1877,11 +2236,25 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1877
2236
|
else if (interactionQuery) {
|
|
1878
2237
|
// InteractionQuery is a must-reply channel, just like exec and KV. AI
|
|
1879
2238
|
// SDK has no Cursor-specific UI callback, so answer immediately with the
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
2239
|
+
// policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
|
|
2240
|
+
// write under OpenCode defaults, GenerateImage approve when saveable).
|
|
1882
2241
|
const handled = (() => {
|
|
1883
2242
|
try {
|
|
1884
|
-
return handleInteractionQuery(interactionQuery, payload
|
|
2243
|
+
return handleInteractionQuery(interactionQuery, payload, {
|
|
2244
|
+
canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
|
|
2245
|
+
allowTools: session.allowTools,
|
|
2246
|
+
advertisedTools: advertisedToolNameSet,
|
|
2247
|
+
canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
|
|
2248
|
+
canBridgeCreatePlan: session.allowTools
|
|
2249
|
+
&& isBridgedCursorPlanModeActive(session.openCodeSessionId)
|
|
2250
|
+
&& advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
|
|
2251
|
+
&& advertisedToolNameSet.has("write"),
|
|
2252
|
+
planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
|
|
2253
|
+
...(getActiveCursorMode(session.openCodeSessionId)
|
|
2254
|
+
? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
|
|
2255
|
+
: {}),
|
|
2256
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2257
|
+
});
|
|
1885
2258
|
}
|
|
1886
2259
|
catch {
|
|
1887
2260
|
return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
|
|
@@ -1891,14 +2264,139 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1891
2264
|
if (handled.outcome === "acknowledged") {
|
|
1892
2265
|
replaySafety.markBarrier("stateful-interaction");
|
|
1893
2266
|
}
|
|
1894
|
-
|
|
2267
|
+
if (handled.reply) {
|
|
2268
|
+
await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
|
|
2269
|
+
}
|
|
1895
2270
|
trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
|
|
1896
|
-
`field=${handled.variantField} outcome=${handled.outcome}`
|
|
2271
|
+
`field=${handled.variantField} outcome=${handled.outcome}` +
|
|
2272
|
+
(handled.reply ? "" : " (deferred to host tool result)"));
|
|
2273
|
+
if (handled.generateImage) {
|
|
2274
|
+
trace(`interaction_query: approved generate_image target=` +
|
|
2275
|
+
`${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
|
|
2276
|
+
`refs=${handled.generateImage.referenceImagePaths.length} ` +
|
|
2277
|
+
`cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
|
|
2278
|
+
}
|
|
1897
2279
|
}
|
|
1898
2280
|
catch (error) {
|
|
1899
2281
|
rethrowTransportWriteFailure(error);
|
|
1900
2282
|
failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
|
|
1901
2283
|
}
|
|
2284
|
+
if (handled.outcome === "bridged" && handled.askQuestion) {
|
|
2285
|
+
// Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
|
|
2286
|
+
// leaves as a `question` tool call and this doStream ends. The Run stays
|
|
2287
|
+
// open on the pending entry, and the answer is written back on the next
|
|
2288
|
+
// continuation (InteractionResponse when Cursor is blocking on it,
|
|
2289
|
+
// ConversationAction when it was already acknowledged with `async`).
|
|
2290
|
+
const ask = handled.askQuestion;
|
|
2291
|
+
const execId = session.nextBridgedExecId++;
|
|
2292
|
+
sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
|
|
2293
|
+
interactionId: handled.id,
|
|
2294
|
+
askQuestionArgs: ask.args,
|
|
2295
|
+
askQuestionRawArgs: ask.rawArgs,
|
|
2296
|
+
askQuestionToolCallId: ask.toolCallId,
|
|
2297
|
+
});
|
|
2298
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2299
|
+
const input = JSON.stringify(askQuestionToolInput(ask.args));
|
|
2300
|
+
trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2301
|
+
`questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
|
|
2302
|
+
`cursorToolCallId=${ask.toolCallId || "(none)"}`);
|
|
2303
|
+
closeOpenSpans();
|
|
2304
|
+
safeEnqueue({
|
|
2305
|
+
type: "tool-call",
|
|
2306
|
+
toolCallId,
|
|
2307
|
+
toolName: "question",
|
|
2308
|
+
input,
|
|
2309
|
+
});
|
|
2310
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (handled.outcome === "approved" && handled.switchMode) {
|
|
2314
|
+
// Entering plan mode without a host plan tool: Cursor was already
|
|
2315
|
+
// released with approved{} above, and the behavioural contract travels
|
|
2316
|
+
// as the <system_reminder> injected on subsequent Runs. Nothing is
|
|
2317
|
+
// pending, so keep pumping this Run rather than ending doStream.
|
|
2318
|
+
const sw = handled.switchMode;
|
|
2319
|
+
setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
|
|
2320
|
+
bridgedPlanEntered: false,
|
|
2321
|
+
});
|
|
2322
|
+
trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
|
|
2323
|
+
`target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
|
|
2324
|
+
`provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2325
|
+
continue;
|
|
2326
|
+
}
|
|
2327
|
+
if (handled.outcome === "bridged" && handled.switchMode) {
|
|
2328
|
+
// Cursor raised SwitchMode; the host owns the outcome, so the switch
|
|
2329
|
+
// leaves as a tool call (native plan_enter / plan_exit, or the emulated
|
|
2330
|
+
// `question` approval prompt) and this doStream ends. The Run stays open
|
|
2331
|
+
// on the pending entry; approved/rejected is written back on the next
|
|
2332
|
+
// continuation while Cursor is still blocking on the query.
|
|
2333
|
+
const sw = handled.switchMode;
|
|
2334
|
+
const bridgeKind = sw.bridge.kind;
|
|
2335
|
+
const toolName = sw.toolName ?? "plan_exit";
|
|
2336
|
+
const execId = session.nextBridgedExecId++;
|
|
2337
|
+
sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
|
|
2338
|
+
interactionId: handled.id,
|
|
2339
|
+
switchModeTarget: sw.args.targetModeId,
|
|
2340
|
+
switchModeToolCallId: sw.toolCallId,
|
|
2341
|
+
switchModeBridgeKind: bridgeKind,
|
|
2342
|
+
});
|
|
2343
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2344
|
+
const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
|
|
2345
|
+
trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2346
|
+
`bridge=${bridgeKind} hostTool=${toolName} ` +
|
|
2347
|
+
`target=${JSON.stringify(sw.args.targetModeId)} ` +
|
|
2348
|
+
`cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2349
|
+
closeOpenSpans();
|
|
2350
|
+
safeEnqueue({
|
|
2351
|
+
type: "tool-call",
|
|
2352
|
+
toolCallId,
|
|
2353
|
+
toolName,
|
|
2354
|
+
input,
|
|
2355
|
+
});
|
|
2356
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
if (handled.outcome === "bridged" && handled.createPlan) {
|
|
2360
|
+
// Either a host plan-stage tool (writes the plan and runs its own
|
|
2361
|
+
// approval UI) or the emulated prompt after the provider already wrote
|
|
2362
|
+
// the plan. Both hold Cursor's query open until the user decides.
|
|
2363
|
+
const plan = handled.createPlan;
|
|
2364
|
+
const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
|
|
2365
|
+
const planUri = staged?.plan_uri ?? plan.planUri ?? "";
|
|
2366
|
+
const input = staged ?? plan.questionInput ?? {};
|
|
2367
|
+
const stageExecId = session.nextBridgedExecId++;
|
|
2368
|
+
sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
|
|
2369
|
+
interactionId: handled.id,
|
|
2370
|
+
createPlanToolCallId: plan.toolCallId,
|
|
2371
|
+
createPlanBridgeKind: plan.bridge.kind,
|
|
2372
|
+
// The host echoes the prompt verbatim; it anchors the answer parse.
|
|
2373
|
+
createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
|
|
2374
|
+
planUri,
|
|
2375
|
+
// Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
|
|
2376
|
+
...(typeof plan.planPath === "string" && plan.planPath.trim()
|
|
2377
|
+
? { planPath: plan.planPath.trim() }
|
|
2378
|
+
: {}),
|
|
2379
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2380
|
+
});
|
|
2381
|
+
const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
|
|
2382
|
+
trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
|
|
2383
|
+
`hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
|
|
2384
|
+
`planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
|
|
2385
|
+
// Cursor sends the plan body through the interaction query, not the
|
|
2386
|
+
// text stream, so nothing has shown it yet. Put it in the transcript
|
|
2387
|
+
// before asking — approving a plan you cannot read is not approval.
|
|
2388
|
+
if (plan.planReview)
|
|
2389
|
+
emitText(plan.planReview);
|
|
2390
|
+
closeOpenSpans();
|
|
2391
|
+
safeEnqueue({
|
|
2392
|
+
type: "tool-call",
|
|
2393
|
+
toolCallId: stageToolCallId,
|
|
2394
|
+
toolName: plan.toolName,
|
|
2395
|
+
input: JSON.stringify(input),
|
|
2396
|
+
});
|
|
2397
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
1902
2400
|
}
|
|
1903
2401
|
else if (kv) {
|
|
1904
2402
|
// KV blob channel: ack set_blob / answer get_blob, then keep pumping.
|
|
@@ -2050,16 +2548,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2050
2548
|
const instructions = [];
|
|
2051
2549
|
const subagents = extractHostSubagentCatalog(tools);
|
|
2052
2550
|
if (names.has("question")) {
|
|
2053
|
-
|
|
2054
|
-
|
|
2551
|
+
// Cursor-native AskQuestion is translated into this tool (see
|
|
2552
|
+
// protocol/ask-question.ts), so both routes reach the user. Saying so stops
|
|
2553
|
+
// the model from treating a question as impossible when it reaches for its
|
|
2554
|
+
// native interaction first.
|
|
2555
|
+
instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
|
|
2556
|
+
}
|
|
2557
|
+
// CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
|
|
2558
|
+
// tool. Always say so when we advertise any tools — otherwise the model sees
|
|
2559
|
+
// CreatePlan in Cursor's function definitions, notices it is absent from the
|
|
2560
|
+
// OpenCode list, and narrates "No CreatePlan MCP tool is available" even
|
|
2561
|
+
// though the provider bridges the native interaction.
|
|
2562
|
+
instructions.push("- Cursor-native CreatePlan is accepted as a Cursor interaction (not an OpenCode or MCP catalog tool). Raise it normally; the provider writes the plan under the host's calculated plans directory and handles execution approval. Do not narrate that CreatePlan is missing, unavailable, or not an MCP tool.");
|
|
2055
2563
|
if (names.has("plan_enter")) {
|
|
2056
|
-
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool
|
|
2564
|
+
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool. Cursor-native SwitchMode requests for plan/spec are also accepted and answered through it.");
|
|
2057
2565
|
}
|
|
2058
2566
|
else if (names.has("todowrite")) {
|
|
2059
|
-
instructions.push("- For planning, call the OpenCode `todowrite` tool and
|
|
2567
|
+
instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
|
|
2060
2568
|
}
|
|
2061
2569
|
if (names.has("plan_exit")) {
|
|
2062
|
-
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
|
|
2570
|
+
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool. Cursor-native SwitchMode for any non-plan target (agent, build, chat, debug, edit, background, multitask, triage, project, …) is also accepted and answered through it; the provider then injects the Cursor CLI-shaped mode reminder for that target.");
|
|
2063
2571
|
}
|
|
2064
2572
|
if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
|
|
2065
2573
|
instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
|
|
@@ -2073,8 +2581,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2073
2581
|
if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
|
|
2074
2582
|
instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
|
|
2075
2583
|
}
|
|
2076
|
-
if (names.has("task")
|
|
2077
|
-
const target =
|
|
2584
|
+
if (names.has("task")) {
|
|
2585
|
+
const target = "`task`";
|
|
2078
2586
|
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
2079
2587
|
instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
|
|
2080
2588
|
"Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
|
|
@@ -2102,8 +2610,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2102
2610
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
2103
2611
|
`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.`,
|
|
2104
2612
|
subagents.executor
|
|
2105
|
-
? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed
|
|
2106
|
-
: "Call only tools in that exact list. Cursor
|
|
2613
|
+
? "Call only tools in that exact list for ordinary host execution. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing."
|
|
2614
|
+
: "Call only tools in that exact list for ordinary host execution. Bridged Cursor interactions named below (AskQuestion, SwitchMode, CreatePlan, …) are not OpenCode/MCP catalog tools — raise them normally and do not narrate that they are missing. Other unlisted Cursor-native tools are not bridged; complete the work with the listed tools or explain the limitation without claiming a missing MCP tool.",
|
|
2107
2615
|
...(instructions.length > 0
|
|
2108
2616
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
2109
2617
|
: []),
|
|
@@ -2286,18 +2794,43 @@ export function spanEndParts(opts) {
|
|
|
2286
2794
|
export function computeAllowTools(toolCount, toolChoice) {
|
|
2287
2795
|
return toolCount > 0 && toolChoice?.type !== "none";
|
|
2288
2796
|
}
|
|
2289
|
-
export function resolveTurnToolState(input) {
|
|
2797
|
+
export async function resolveTurnToolState(input) {
|
|
2290
2798
|
const { sessionKey, incomingTools, isCompaction } = input;
|
|
2291
2799
|
if (sessionKey && incomingTools.length > 0) {
|
|
2292
2800
|
rememberToolCatalog(sessionKey, incomingTools.map((tool) => ({ ...tool })));
|
|
2293
2801
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2802
|
+
// Advertisement and permission are deliberately independent.
|
|
2803
|
+
//
|
|
2804
|
+
// Advertisement must stay byte-stable across every Run of a conversation or
|
|
2805
|
+
// the RequestContext changes shape and Cursor's prompt cache goes cold. A
|
|
2806
|
+
// zero-tool call is never a smaller catalog — it is a lifecycle turn
|
|
2807
|
+
// (compaction, title generation, summarization) that OpenCode opens alongside
|
|
2808
|
+
// the real one — so it re-advertises the last real catalog rather than
|
|
2809
|
+
// collapsing the context to tools=0. A genuinely restricted turn still sends
|
|
2810
|
+
// a non-empty set and is advertised verbatim.
|
|
2811
|
+
//
|
|
2812
|
+
// On cold start the lifecycle Run may arrive before any catalog exists. For a
|
|
2813
|
+
// valid session key, wait until a sibling doStream publishes the first real
|
|
2814
|
+
// catalog; cancellation is the only escape. Never time out to tools=[] and
|
|
2815
|
+
// never invent or filter the enabled set.
|
|
2816
|
+
//
|
|
2817
|
+
// Permission is computed from what actually arrived, so those lifecycle turns
|
|
2818
|
+
// still refuse execution and cannot duplicate the real turn's side effects.
|
|
2819
|
+
let advertisedTools;
|
|
2820
|
+
if (incomingTools.length > 0) {
|
|
2821
|
+
advertisedTools = incomingTools;
|
|
2822
|
+
}
|
|
2823
|
+
else if (sessionKey) {
|
|
2824
|
+
const cached = toolCatalogBySession.get(sessionKey)
|
|
2825
|
+
?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
|
|
2826
|
+
advertisedTools = cached.map((tool) => ({ ...tool }));
|
|
2827
|
+
}
|
|
2828
|
+
else {
|
|
2829
|
+
// Without a session key there is no safe sibling-correlation key. Preserve
|
|
2830
|
+
// the host's literal no-tool call rather than borrowing another session's
|
|
2831
|
+
// catalog.
|
|
2832
|
+
advertisedTools = [];
|
|
2297
2833
|
}
|
|
2298
|
-
const advertisedTools = isCompaction && incomingTools.length === 0
|
|
2299
|
-
? (cached?.map((tool) => ({ ...tool })) ?? [])
|
|
2300
|
-
: incomingTools;
|
|
2301
2834
|
return {
|
|
2302
2835
|
advertisedTools,
|
|
2303
2836
|
allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
|
|
@@ -2316,6 +2849,11 @@ export function resolveTurnConversationReset(input) {
|
|
|
2316
2849
|
return { reset: false };
|
|
2317
2850
|
}
|
|
2318
2851
|
export function resetTurnStateForTests() {
|
|
2852
|
+
for (const waiters of toolCatalogWaitersBySession.values()) {
|
|
2853
|
+
for (const waiter of waiters)
|
|
2854
|
+
waiter.cancel();
|
|
2855
|
+
}
|
|
2856
|
+
toolCatalogWaitersBySession.clear();
|
|
2319
2857
|
toolCatalogBySession.clear();
|
|
2320
2858
|
postCompactionRebaseBySession.clear();
|
|
2321
2859
|
}
|