cursor-opencode-provider 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -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/plugins.js +1 -0
- package/dist/context/rules.js +2 -1
- package/dist/context/skills.js +1 -0
- 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 +589 -41
- 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/conversation-bind.d.ts +1 -0
- package/dist/protocol/conversation-bind.js +3 -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/struct.js +1 -1
- 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 +98 -42
- 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);
|
|
@@ -548,12 +625,18 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
548
625
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
549
626
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
550
627
|
// system prompt and OpenCode's newly compacted history.
|
|
628
|
+
const lifecycle = !allowTools && !isCompaction && !recovery;
|
|
551
629
|
let bound = resuming
|
|
552
630
|
? { conversationId: resumeRecovery.conversationId, reset: false, previousId: undefined }
|
|
553
|
-
: bindConversationId(sessionKey, {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
631
|
+
: bindConversationId(sessionKey, {
|
|
632
|
+
reset: resetState.reset || recovery?.kind === "rebase",
|
|
633
|
+
ephemeral: lifecycle,
|
|
634
|
+
});
|
|
635
|
+
let conversationState = lifecycle
|
|
636
|
+
? undefined
|
|
637
|
+
: resuming
|
|
638
|
+
? resumeRecovery.checkpoint
|
|
639
|
+
: (bound.reset ? undefined : getCheckpoint(bound.conversationId));
|
|
557
640
|
let checkpointGraph = conversationState
|
|
558
641
|
? inspectConversationBlobGraph(bound.conversationId, conversationState)
|
|
559
642
|
: { count: 0, bytes: 0, complete: true };
|
|
@@ -606,9 +689,25 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
606
689
|
const workspaceRoot = path.resolve(getSessionDirectory(sessionKey) ?? (options.workspaceRoot || process.cwd()));
|
|
607
690
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
608
691
|
const interactionGuidance = buildOpenCodeInteractionGuidance(cursorTools, isCompaction, workspaceRoot);
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
692
|
+
// After an approved SwitchMode, inject the Cursor CLI-shaped mode reminder
|
|
693
|
+
// (same <system_reminder> contract the CLI uses after flipping unifiedMode).
|
|
694
|
+
const startedWithCheckpoint = !!conversationState;
|
|
695
|
+
const modeReminder = isCompaction || startedWithCheckpoint || lifecycle
|
|
696
|
+
? undefined
|
|
697
|
+
: takeActiveCursorModeReminder(sessionKey, {
|
|
698
|
+
advertisedTools: cursorTools.map((tool) => tool.name),
|
|
699
|
+
});
|
|
700
|
+
const kickoffWarning = isCompaction || startedWithCheckpoint || lifecycle
|
|
701
|
+
? undefined
|
|
702
|
+
: takePlanExecutionKickoffWarning(sessionKey);
|
|
703
|
+
const systemPrompt = [
|
|
704
|
+
baseSystemPrompt,
|
|
705
|
+
interactionGuidance,
|
|
706
|
+
modeReminder,
|
|
707
|
+
kickoffWarning ? `<system_reminder>${kickoffWarning}</system_reminder>` : undefined,
|
|
708
|
+
]
|
|
709
|
+
.filter(Boolean)
|
|
710
|
+
.join("\n\n");
|
|
612
711
|
const history = extractPromptHistory(prompt, {
|
|
613
712
|
preserveTrailingUser: recovery?.kind === "rebase",
|
|
614
713
|
toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
|
|
@@ -806,7 +905,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
806
905
|
displayToolCalls: 0,
|
|
807
906
|
execRequests: 0,
|
|
808
907
|
},
|
|
809
|
-
openCodeSessionId: sessionKey,
|
|
908
|
+
openCodeSessionId: lifecycle ? undefined : sessionKey,
|
|
810
909
|
postCompactionRebase: isCompaction,
|
|
811
910
|
toolCatalog: snapshotToolCatalog(sessionKey),
|
|
812
911
|
stream,
|
|
@@ -886,6 +985,124 @@ export function findContinuationSession(toolResults) {
|
|
|
886
985
|
}
|
|
887
986
|
return undefined;
|
|
888
987
|
}
|
|
988
|
+
/**
|
|
989
|
+
* Turn an OpenCode `question` tool result into the frame Cursor is waiting for.
|
|
990
|
+
*
|
|
991
|
+
* A synchronous AskQuestion still has its InteractionQuery open, so the answer
|
|
992
|
+
* goes back as the InteractionResponse for that query id. A `run_async` one was
|
|
993
|
+
* already released with `async{}`, so — exactly as Cursor CLI does — the answer
|
|
994
|
+
* travels as a ConversationAction keyed by the originating tool call id, with
|
|
995
|
+
* the server's own AskQuestionArgs bytes echoed back untouched.
|
|
996
|
+
*/
|
|
997
|
+
function buildAskQuestionContinuationFrame(pending, result) {
|
|
998
|
+
const metadata = pending.resultMetadata ?? {};
|
|
999
|
+
const args = metadata.askQuestionArgs;
|
|
1000
|
+
if (!args)
|
|
1001
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its decoded arguments");
|
|
1002
|
+
const answer = askQuestionResultFromToolOutput(args, result.output, result.error !== undefined);
|
|
1003
|
+
if (!args.runAsync) {
|
|
1004
|
+
const interactionId = metadata.interactionId;
|
|
1005
|
+
if (typeof interactionId !== "number") {
|
|
1006
|
+
throw new CursorProtocolError("Bridged AskQuestion lost its interaction id");
|
|
1007
|
+
}
|
|
1008
|
+
return buildAskQuestionInteractionReply(interactionId, answer);
|
|
1009
|
+
}
|
|
1010
|
+
const rawArgs = metadata.askQuestionRawArgs;
|
|
1011
|
+
if (!(rawArgs instanceof Uint8Array)) {
|
|
1012
|
+
throw new CursorProtocolError("Bridged async AskQuestion lost its original arguments");
|
|
1013
|
+
}
|
|
1014
|
+
const toolCallId = metadata.askQuestionToolCallId;
|
|
1015
|
+
return buildAsyncAskQuestionCompletion(typeof toolCallId === "string" ? toolCallId : "", rawArgs, answer);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Turn the host's plan-approval outcome into the CreatePlan reply Cursor is
|
|
1019
|
+
* still blocking on. One contract across every channel: `success` means the
|
|
1020
|
+
* user approved execution, `error` means the plan was written but not accepted,
|
|
1021
|
+
* so the model keeps planning instead of starting work.
|
|
1022
|
+
*
|
|
1023
|
+
* A host plan-stage tool reports that outcome as tool success/failure. The
|
|
1024
|
+
* emulated path asks through `question`, so only an explicit "Yes" approves.
|
|
1025
|
+
* On approval the session leaves plan mode, which arms the agent-mode
|
|
1026
|
+
* `<system_reminder>` for the next Run.
|
|
1027
|
+
*/
|
|
1028
|
+
function buildCreatePlanContinuationFrame(pending, result, sessionKey, cursorSessionID) {
|
|
1029
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1030
|
+
const interactionId = metadata.interactionId;
|
|
1031
|
+
if (typeof interactionId !== "number") {
|
|
1032
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its interaction id");
|
|
1033
|
+
}
|
|
1034
|
+
const planUri = metadata.planUri;
|
|
1035
|
+
if (typeof planUri !== "string" || !planUri.trim()) {
|
|
1036
|
+
throw new CursorProtocolError("Bridged CreatePlan lost its plan URI");
|
|
1037
|
+
}
|
|
1038
|
+
const approved = metadata.createPlanBridgeKind === "approve"
|
|
1039
|
+
? createPlanApproved(result.output, result.error !== undefined, typeof metadata.createPlanQuestion === "string" ? metadata.createPlanQuestion : "")
|
|
1040
|
+
: result.error === undefined;
|
|
1041
|
+
if (!approved) {
|
|
1042
|
+
const reason = result.error?.trim();
|
|
1043
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1044
|
+
error: { error: reason || CREATE_PLAN_NOT_APPROVED_REASON },
|
|
1045
|
+
plan_uri: "",
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
// The question-emulated path can report execution approval only when a host
|
|
1049
|
+
// kickoff exists. Otherwise keep planning mode active and return an error.
|
|
1050
|
+
if (metadata.createPlanBridgeKind === "approve" && !hasPlanExecutionKickoff()) {
|
|
1051
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1052
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1053
|
+
plan_uri: planUri,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
// Approved: planning is over for this plan, so drop the plan-mode reminder
|
|
1057
|
+
// and hand the next Run the agent-mode contract, exactly as an approved
|
|
1058
|
+
// plan_exit does.
|
|
1059
|
+
setActiveCursorMode(sessionKey, "agent");
|
|
1060
|
+
// Only the question-emulated path needs the provider's synthetic build turn.
|
|
1061
|
+
// A host stage tool owns both approval and execution handoff itself.
|
|
1062
|
+
if (metadata.createPlanBridgeKind === "approve" && sessionKey) {
|
|
1063
|
+
const absolute = typeof metadata.planPath === "string" && metadata.planPath.trim()
|
|
1064
|
+
? metadata.planPath.trim()
|
|
1065
|
+
: planPathFromUri(planUri);
|
|
1066
|
+
const workspaceRoot = typeof metadata.workspaceRoot === "string" ? metadata.workspaceRoot : undefined;
|
|
1067
|
+
if (!queuePlanExecutionKickoff({
|
|
1068
|
+
sessionID: sessionKey,
|
|
1069
|
+
planPath: formatPlanKickoffPath(absolute, workspaceRoot),
|
|
1070
|
+
cursorSessionID,
|
|
1071
|
+
})) {
|
|
1072
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1073
|
+
error: { error: "The plan was approved, but this host cannot start its execution turn." },
|
|
1074
|
+
plan_uri: planUri,
|
|
1075
|
+
});
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return buildCreatePlanInteractionReply(interactionId, {
|
|
1079
|
+
success: {},
|
|
1080
|
+
plan_uri: planUri,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
function buildSwitchModeContinuationFrame(pending, result, sessionKey) {
|
|
1084
|
+
const metadata = pending.resultMetadata ?? {};
|
|
1085
|
+
const interactionId = metadata.interactionId;
|
|
1086
|
+
if (typeof interactionId !== "number") {
|
|
1087
|
+
throw new CursorProtocolError("Bridged SwitchMode lost its interaction id");
|
|
1088
|
+
}
|
|
1089
|
+
// A question-emulated exit carries the user's Yes/No as prose, not a plan
|
|
1090
|
+
// tool's success/failure, so it needs the answer parser rather than the
|
|
1091
|
+
// error-shaped mapping.
|
|
1092
|
+
const answer = metadata.switchModeBridgeKind === "question"
|
|
1093
|
+
? switchModeResultFromQuestionOutput(result.output, result.error !== undefined)
|
|
1094
|
+
: switchModeResultFromToolOutput(result.output, result.error !== undefined);
|
|
1095
|
+
if ("approved" in answer) {
|
|
1096
|
+
const target = metadata.switchModeTarget;
|
|
1097
|
+
if (typeof target === "string" && target.trim()) {
|
|
1098
|
+
const normalized = target.trim().toLowerCase();
|
|
1099
|
+
setActiveCursorMode(sessionKey, target, {
|
|
1100
|
+
bridgedPlanEntered: normalized === "plan" || normalized === "spec",
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return buildSwitchModeInteractionReply(interactionId, answer);
|
|
1105
|
+
}
|
|
889
1106
|
/**
|
|
890
1107
|
* Deliver trailing tool results onto a live continuation session.
|
|
891
1108
|
* Returns the same session when writes succeed (or only bridged results were
|
|
@@ -912,7 +1129,44 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
912
1129
|
}
|
|
913
1130
|
const pending = claim.pending;
|
|
914
1131
|
let frames = [];
|
|
915
|
-
if (
|
|
1132
|
+
if (pending.resultField === ASK_QUESTION_RESULT_FIELD) {
|
|
1133
|
+
// A bridged Cursor AskQuestion. The host tool result carries the user's
|
|
1134
|
+
// choices; translate them back into the Cursor result the interaction is
|
|
1135
|
+
// waiting for (or into the deferred completion, when Cursor was already
|
|
1136
|
+
// released with `async`).
|
|
1137
|
+
try {
|
|
1138
|
+
frames = [buildAskQuestionContinuationFrame(pending, r)];
|
|
1139
|
+
}
|
|
1140
|
+
catch (error) {
|
|
1141
|
+
trace(`continuation: ask_question encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1142
|
+
sessionManager.close(session, "result-write-failed");
|
|
1143
|
+
return undefined;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
else if (pending.resultField === SWITCH_MODE_RESULT_FIELD) {
|
|
1147
|
+
// Bridged SwitchMode: host plan_enter / plan_exit outcome becomes
|
|
1148
|
+
// approved{} or rejected{reason} on the still-open InteractionQuery.
|
|
1149
|
+
// Approval also arms the CLI-shaped mode reminder for the next Run.
|
|
1150
|
+
try {
|
|
1151
|
+
frames = [buildSwitchModeContinuationFrame(pending, r, session.openCodeSessionId)];
|
|
1152
|
+
}
|
|
1153
|
+
catch (error) {
|
|
1154
|
+
trace(`continuation: switch_mode encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1155
|
+
sessionManager.close(session, "result-write-failed");
|
|
1156
|
+
return undefined;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
else if (pending.resultField === CREATE_PLAN_RESULT_FIELD) {
|
|
1160
|
+
try {
|
|
1161
|
+
frames = [buildCreatePlanContinuationFrame(pending, r, session.openCodeSessionId, session.sessionId)];
|
|
1162
|
+
}
|
|
1163
|
+
catch (error) {
|
|
1164
|
+
trace(`continuation: create_plan encode FAILED execId=${r.execId} err=${error.message}`);
|
|
1165
|
+
sessionManager.close(session, "result-write-failed");
|
|
1166
|
+
return undefined;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
else if (!pending.bridged) {
|
|
916
1170
|
try {
|
|
917
1171
|
const shellResult = pending.resultField === "shell_stream"
|
|
918
1172
|
|| pending.resultField === "background_shell_spawn_result"
|
|
@@ -939,6 +1193,55 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
939
1193
|
}
|
|
940
1194
|
}
|
|
941
1195
|
}
|
|
1196
|
+
// A refused image commit is Cursor's own `permission_denied` variant,
|
|
1197
|
+
// not a generic write error: its agent branches on that case. The
|
|
1198
|
+
// marker is set by this provider's own tool, so matching it is a
|
|
1199
|
+
// contract rather than a guess at someone else's message text.
|
|
1200
|
+
if (pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1201
|
+
&& pending.resultField === "write_result"
|
|
1202
|
+
&& r.error?.includes(IMAGE_PERMISSION_DENIED_PREFIX)) {
|
|
1203
|
+
const deniedPath = typeof pending.resultMetadata?.path === "string"
|
|
1204
|
+
? pending.resultMetadata.path
|
|
1205
|
+
: "";
|
|
1206
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1207
|
+
exec_client_message: {
|
|
1208
|
+
id: r.execId,
|
|
1209
|
+
write_result: {
|
|
1210
|
+
permission_denied: {
|
|
1211
|
+
path: deniedPath,
|
|
1212
|
+
directory: deniedPath ? path.dirname(deniedPath) : "",
|
|
1213
|
+
operation: "write",
|
|
1214
|
+
error: r.error.replace(`${IMAGE_PERMISSION_DENIED_PREFIX} `, ""),
|
|
1215
|
+
is_readonly: false,
|
|
1216
|
+
},
|
|
1217
|
+
},
|
|
1218
|
+
},
|
|
1219
|
+
})];
|
|
1220
|
+
}
|
|
1221
|
+
// A committed image reports the file Cursor asked about, not the tool's
|
|
1222
|
+
// prose. `buildExecClientMessages` would otherwise put the message's
|
|
1223
|
+
// character count in `WriteSuccess.file_size`.
|
|
1224
|
+
if (frames.length === 0
|
|
1225
|
+
&& !r.error
|
|
1226
|
+
&& pending.toolName === CURSOR_IMAGE_SAVE_TOOL
|
|
1227
|
+
&& pending.resultField === "write_result") {
|
|
1228
|
+
frames = [encodeMessage("AgentClientMessage", {
|
|
1229
|
+
exec_client_message: {
|
|
1230
|
+
id: r.execId,
|
|
1231
|
+
write_result: {
|
|
1232
|
+
success: {
|
|
1233
|
+
path: typeof pending.resultMetadata?.path === "string"
|
|
1234
|
+
? pending.resultMetadata.path
|
|
1235
|
+
: "",
|
|
1236
|
+
lines_created: 0,
|
|
1237
|
+
file_size: typeof pending.resultMetadata?.imageByteLength === "number"
|
|
1238
|
+
? pending.resultMetadata.imageByteLength
|
|
1239
|
+
: 0,
|
|
1240
|
+
},
|
|
1241
|
+
},
|
|
1242
|
+
},
|
|
1243
|
+
})];
|
|
1244
|
+
}
|
|
942
1245
|
if (frames.length === 0) {
|
|
943
1246
|
frames = buildExecClientMessages({
|
|
944
1247
|
execId: r.execId,
|
|
@@ -970,7 +1273,14 @@ export function deliverContinuationResults(session, trailingToolResults) {
|
|
|
970
1273
|
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
971
1274
|
continue;
|
|
972
1275
|
}
|
|
973
|
-
|
|
1276
|
+
const resultKind = pending.resultField === ASK_QUESTION_RESULT_FIELD
|
|
1277
|
+
? "ask_question answer"
|
|
1278
|
+
: pending.resultField === SWITCH_MODE_RESULT_FIELD
|
|
1279
|
+
? "switch_mode answer"
|
|
1280
|
+
: pending.resultField === CREATE_PLAN_RESULT_FIELD
|
|
1281
|
+
? "create_plan answer"
|
|
1282
|
+
: "exec result";
|
|
1283
|
+
trace(`continuation: wrote ${resultKind} execId=${r.execId} field=${pending.resultField} ` +
|
|
974
1284
|
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
975
1285
|
}
|
|
976
1286
|
return session;
|
|
@@ -1315,10 +1625,9 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1315
1625
|
const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
1316
1626
|
if (!requested)
|
|
1317
1627
|
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.
|
|
1628
|
+
// A scheme-addressed target is the advertised host executor's to resolve,
|
|
1629
|
+
// not a local file to stat. Rejecting it here would make any URI-backed
|
|
1630
|
+
// capability permanently unreachable through this provider.
|
|
1322
1631
|
if (isUriReadTarget(requested)) {
|
|
1323
1632
|
trace(`exec: forwarding URI read target id=${parsed.id} target=${JSON.stringify(requested)}`);
|
|
1324
1633
|
return false;
|
|
@@ -1811,6 +2120,63 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1811
2120
|
return;
|
|
1812
2121
|
continue;
|
|
1813
2122
|
}
|
|
2123
|
+
// Cursor writes a generated image with an ordinary write exec whose
|
|
2124
|
+
// `file_bytes` are binary (Cursor CLI's agent does exactly this, then
|
|
2125
|
+
// reads the client's WriteResult back). OpenCode's `write` takes a
|
|
2126
|
+
// string, so those bytes are staged and committed by the host tool
|
|
2127
|
+
// that can raise the `edit` permission before anything hits disk.
|
|
2128
|
+
const binaryWrite = binaryWritePayload(parsed);
|
|
2129
|
+
if (binaryWrite) {
|
|
2130
|
+
if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
|
|
2131
|
+
const reason = "This OpenCode agent cannot write binary file content. "
|
|
2132
|
+
+ "Do not retry this write with the same bytes.";
|
|
2133
|
+
if (!rejectExec(parsed, reason, "binary write unsupported"))
|
|
2134
|
+
return;
|
|
2135
|
+
continue;
|
|
2136
|
+
}
|
|
2137
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
2138
|
+
const projectDir = ensureOpencodeProjectDir(workspaceRoot);
|
|
2139
|
+
const target = remapCursorImageWritePath(binaryWrite.path, {
|
|
2140
|
+
workspaceRoot,
|
|
2141
|
+
projectDir,
|
|
2142
|
+
});
|
|
2143
|
+
let imageId;
|
|
2144
|
+
try {
|
|
2145
|
+
imageId = stageCursorImage({
|
|
2146
|
+
path: target,
|
|
2147
|
+
projectDir,
|
|
2148
|
+
mime: imageMimeForPath(target),
|
|
2149
|
+
data: binaryWrite.data,
|
|
2150
|
+
sessionId: session.openCodeSessionId,
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
if (!rejectExec(parsed, error.message, "binary write too large"))
|
|
2155
|
+
return;
|
|
2156
|
+
continue;
|
|
2157
|
+
}
|
|
2158
|
+
if (displayCallId)
|
|
2159
|
+
session.displayToolCalls.delete(displayCallId);
|
|
2160
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, CURSOR_IMAGE_SAVE_TOOL, false, {
|
|
2161
|
+
...parsed.resultMetadata,
|
|
2162
|
+
path: target,
|
|
2163
|
+
binaryWriteBytes: undefined,
|
|
2164
|
+
imageByteLength: binaryWrite.data.length,
|
|
2165
|
+
});
|
|
2166
|
+
const toolCallId = `cursor_${session.sessionId}_${parsed.id}`;
|
|
2167
|
+
trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
|
|
2168
|
+
`requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
|
|
2169
|
+
`bytes=${binaryWrite.data.length}`);
|
|
2170
|
+
closeOpenSpans();
|
|
2171
|
+
safeEnqueue({
|
|
2172
|
+
type: "tool-call",
|
|
2173
|
+
toolCallId,
|
|
2174
|
+
toolName: CURSOR_IMAGE_SAVE_TOOL,
|
|
2175
|
+
input: JSON.stringify({ image_id: imageId }),
|
|
2176
|
+
});
|
|
2177
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
1814
2180
|
// Cursor has native capabilities (Task, filesystem, shell, etc.) in
|
|
1815
2181
|
// addition to the MCP descriptors sent by this provider. The model
|
|
1816
2182
|
// can request one even when the current OpenCode agent omitted its
|
|
@@ -1877,11 +2243,25 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1877
2243
|
else if (interactionQuery) {
|
|
1878
2244
|
// InteractionQuery is a must-reply channel, just like exec and KV. AI
|
|
1879
2245
|
// SDK has no Cursor-specific UI callback, so answer immediately with the
|
|
1880
|
-
//
|
|
1881
|
-
//
|
|
2246
|
+
// policy from protocol/interactions.ts (AskQuestion bridge, CreatePlan
|
|
2247
|
+
// write under OpenCode defaults, GenerateImage approve when saveable).
|
|
1882
2248
|
const handled = (() => {
|
|
1883
2249
|
try {
|
|
1884
|
-
return handleInteractionQuery(interactionQuery, payload
|
|
2250
|
+
return handleInteractionQuery(interactionQuery, payload, {
|
|
2251
|
+
canBridgeAskQuestion: session.allowTools && advertisedToolNameSet.has("question"),
|
|
2252
|
+
allowTools: session.allowTools,
|
|
2253
|
+
advertisedTools: advertisedToolNameSet,
|
|
2254
|
+
canSaveGeneratedImage: session.allowTools && advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL),
|
|
2255
|
+
canBridgeCreatePlan: session.allowTools
|
|
2256
|
+
&& isBridgedCursorPlanModeActive(session.openCodeSessionId)
|
|
2257
|
+
&& advertisedToolNameSet.has(CURSOR_PLAN_STAGE_TOOL)
|
|
2258
|
+
&& advertisedToolNameSet.has("write"),
|
|
2259
|
+
planModeActive: isCursorPlanModeActive(session.openCodeSessionId),
|
|
2260
|
+
...(getActiveCursorMode(session.openCodeSessionId)
|
|
2261
|
+
? { activeCursorModeId: getActiveCursorMode(session.openCodeSessionId) }
|
|
2262
|
+
: {}),
|
|
2263
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2264
|
+
});
|
|
1885
2265
|
}
|
|
1886
2266
|
catch {
|
|
1887
2267
|
return failRunProtocol("Cursor interaction request could not be handled", RUN_REQUEST_UNSUPPORTED);
|
|
@@ -1891,14 +2271,139 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1891
2271
|
if (handled.outcome === "acknowledged") {
|
|
1892
2272
|
replaySafety.markBarrier("stateful-interaction");
|
|
1893
2273
|
}
|
|
1894
|
-
|
|
2274
|
+
if (handled.reply) {
|
|
2275
|
+
await writeWithBackpressure(session.stream, handled.reply, `interaction reply id=${handled.id}`);
|
|
2276
|
+
}
|
|
1895
2277
|
trace(`interaction_query: replied id=${handled.id} variant=${handled.variantName} ` +
|
|
1896
|
-
`field=${handled.variantField} outcome=${handled.outcome}`
|
|
2278
|
+
`field=${handled.variantField} outcome=${handled.outcome}` +
|
|
2279
|
+
(handled.reply ? "" : " (deferred to host tool result)"));
|
|
2280
|
+
if (handled.generateImage) {
|
|
2281
|
+
trace(`interaction_query: approved generate_image target=` +
|
|
2282
|
+
`${JSON.stringify(handled.generateImage.filePath || "(unspecified)")} ` +
|
|
2283
|
+
`refs=${handled.generateImage.referenceImagePaths.length} ` +
|
|
2284
|
+
`cursorToolCallId=${handled.generateImage.toolCallId || "(none)"}`);
|
|
2285
|
+
}
|
|
1897
2286
|
}
|
|
1898
2287
|
catch (error) {
|
|
1899
2288
|
rethrowTransportWriteFailure(error);
|
|
1900
2289
|
failRunProtocol("Cursor interaction reply failed", RUN_REPLY_FAILED);
|
|
1901
2290
|
}
|
|
2291
|
+
if (handled.outcome === "bridged" && handled.askQuestion) {
|
|
2292
|
+
// Cursor raised AskQuestion; OpenCode owns the tool loop, so the prompt
|
|
2293
|
+
// leaves as a `question` tool call and this doStream ends. The Run stays
|
|
2294
|
+
// open on the pending entry, and the answer is written back on the next
|
|
2295
|
+
// continuation (InteractionResponse when Cursor is blocking on it,
|
|
2296
|
+
// ConversationAction when it was already acknowledged with `async`).
|
|
2297
|
+
const ask = handled.askQuestion;
|
|
2298
|
+
const execId = session.nextBridgedExecId++;
|
|
2299
|
+
sessionManager.registerPending(execId, session, ASK_QUESTION_RESULT_FIELD, "question", false, {
|
|
2300
|
+
interactionId: handled.id,
|
|
2301
|
+
askQuestionArgs: ask.args,
|
|
2302
|
+
askQuestionRawArgs: ask.rawArgs,
|
|
2303
|
+
askQuestionToolCallId: ask.toolCallId,
|
|
2304
|
+
});
|
|
2305
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2306
|
+
const input = JSON.stringify(askQuestionToolInput(ask.args));
|
|
2307
|
+
trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2308
|
+
`questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
|
|
2309
|
+
`cursorToolCallId=${ask.toolCallId || "(none)"}`);
|
|
2310
|
+
closeOpenSpans();
|
|
2311
|
+
safeEnqueue({
|
|
2312
|
+
type: "tool-call",
|
|
2313
|
+
toolCallId,
|
|
2314
|
+
toolName: "question",
|
|
2315
|
+
input,
|
|
2316
|
+
});
|
|
2317
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
if (handled.outcome === "approved" && handled.switchMode) {
|
|
2321
|
+
// Entering plan mode without a host plan tool: Cursor was already
|
|
2322
|
+
// released with approved{} above, and the behavioural contract travels
|
|
2323
|
+
// as the <system_reminder> injected on subsequent Runs. Nothing is
|
|
2324
|
+
// pending, so keep pumping this Run rather than ending doStream.
|
|
2325
|
+
const sw = handled.switchMode;
|
|
2326
|
+
setActiveCursorMode(session.openCodeSessionId, sw.args.targetModeId, {
|
|
2327
|
+
bridgedPlanEntered: false,
|
|
2328
|
+
});
|
|
2329
|
+
trace(`interaction_query: APPROVED switch_mode id=${handled.id} ` +
|
|
2330
|
+
`target=${JSON.stringify(sw.args.targetModeId)} (no host plan tool; ` +
|
|
2331
|
+
`provider-owned mode) cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
2334
|
+
if (handled.outcome === "bridged" && handled.switchMode) {
|
|
2335
|
+
// Cursor raised SwitchMode; the host owns the outcome, so the switch
|
|
2336
|
+
// leaves as a tool call (native plan_enter / plan_exit, or the emulated
|
|
2337
|
+
// `question` approval prompt) and this doStream ends. The Run stays open
|
|
2338
|
+
// on the pending entry; approved/rejected is written back on the next
|
|
2339
|
+
// continuation while Cursor is still blocking on the query.
|
|
2340
|
+
const sw = handled.switchMode;
|
|
2341
|
+
const bridgeKind = sw.bridge.kind;
|
|
2342
|
+
const toolName = sw.toolName ?? "plan_exit";
|
|
2343
|
+
const execId = session.nextBridgedExecId++;
|
|
2344
|
+
sessionManager.registerPending(execId, session, SWITCH_MODE_RESULT_FIELD, toolName, false, {
|
|
2345
|
+
interactionId: handled.id,
|
|
2346
|
+
switchModeTarget: sw.args.targetModeId,
|
|
2347
|
+
switchModeToolCallId: sw.toolCallId,
|
|
2348
|
+
switchModeBridgeKind: bridgeKind,
|
|
2349
|
+
});
|
|
2350
|
+
const toolCallId = `cursor_${session.sessionId}_${execId}`;
|
|
2351
|
+
const input = JSON.stringify(sw.bridge.kind === "question" ? sw.bridge.input : switchModeToolInput());
|
|
2352
|
+
trace(`interaction_query: BRIDGED switch_mode id=${handled.id} toolCallId=${toolCallId} ` +
|
|
2353
|
+
`bridge=${bridgeKind} hostTool=${toolName} ` +
|
|
2354
|
+
`target=${JSON.stringify(sw.args.targetModeId)} ` +
|
|
2355
|
+
`cursorToolCallId=${sw.toolCallId || "(none)"}`);
|
|
2356
|
+
closeOpenSpans();
|
|
2357
|
+
safeEnqueue({
|
|
2358
|
+
type: "tool-call",
|
|
2359
|
+
toolCallId,
|
|
2360
|
+
toolName,
|
|
2361
|
+
input,
|
|
2362
|
+
});
|
|
2363
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
if (handled.outcome === "bridged" && handled.createPlan) {
|
|
2367
|
+
// Either a host plan-stage tool (writes the plan and runs its own
|
|
2368
|
+
// approval UI) or the emulated prompt after the provider already wrote
|
|
2369
|
+
// the plan. Both hold Cursor's query open until the user decides.
|
|
2370
|
+
const plan = handled.createPlan;
|
|
2371
|
+
const staged = plan.bridge.kind === "stage" ? createPlanStageInput(plan.args) : undefined;
|
|
2372
|
+
const planUri = staged?.plan_uri ?? plan.planUri ?? "";
|
|
2373
|
+
const input = staged ?? plan.questionInput ?? {};
|
|
2374
|
+
const stageExecId = session.nextBridgedExecId++;
|
|
2375
|
+
sessionManager.registerPending(stageExecId, session, CREATE_PLAN_RESULT_FIELD, plan.toolName, false, {
|
|
2376
|
+
interactionId: handled.id,
|
|
2377
|
+
createPlanToolCallId: plan.toolCallId,
|
|
2378
|
+
createPlanBridgeKind: plan.bridge.kind,
|
|
2379
|
+
// The host echoes the prompt verbatim; it anchors the answer parse.
|
|
2380
|
+
createPlanQuestion: plan.questionInput?.questions[0]?.question ?? "",
|
|
2381
|
+
planUri,
|
|
2382
|
+
// Absolute path for the post-Yes OpenCode kickoff (plan_exit shape).
|
|
2383
|
+
...(typeof plan.planPath === "string" && plan.planPath.trim()
|
|
2384
|
+
? { planPath: plan.planPath.trim() }
|
|
2385
|
+
: {}),
|
|
2386
|
+
workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
|
|
2387
|
+
});
|
|
2388
|
+
const stageToolCallId = `cursor_${session.sessionId}_${stageExecId}`;
|
|
2389
|
+
trace(`interaction_query: BRIDGED create_plan id=${handled.id} bridge=${plan.bridge.kind} ` +
|
|
2390
|
+
`hostTool=${plan.toolName} stageToolCallId=${stageToolCallId} ` +
|
|
2391
|
+
`planUri=${planUri || "(none)"} cursorToolCallId=${plan.toolCallId || "(none)"}`);
|
|
2392
|
+
// Cursor sends the plan body through the interaction query, not the
|
|
2393
|
+
// text stream, so nothing has shown it yet. Put it in the transcript
|
|
2394
|
+
// before asking — approving a plan you cannot read is not approval.
|
|
2395
|
+
if (plan.planReview)
|
|
2396
|
+
emitText(plan.planReview);
|
|
2397
|
+
closeOpenSpans();
|
|
2398
|
+
safeEnqueue({
|
|
2399
|
+
type: "tool-call",
|
|
2400
|
+
toolCallId: stageToolCallId,
|
|
2401
|
+
toolName: plan.toolName,
|
|
2402
|
+
input: JSON.stringify(input),
|
|
2403
|
+
});
|
|
2404
|
+
emitFinish(undefined, { unified: "tool-calls", raw: undefined });
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
1902
2407
|
}
|
|
1903
2408
|
else if (kv) {
|
|
1904
2409
|
// KV blob channel: ack set_blob / answer get_blob, then keep pumping.
|
|
@@ -2050,16 +2555,26 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2050
2555
|
const instructions = [];
|
|
2051
2556
|
const subagents = extractHostSubagentCatalog(tools);
|
|
2052
2557
|
if (names.has("question")) {
|
|
2053
|
-
|
|
2054
|
-
|
|
2558
|
+
// Cursor-native AskQuestion is translated into this tool (see
|
|
2559
|
+
// protocol/ask-question.ts), so both routes reach the user. Saying so stops
|
|
2560
|
+
// the model from treating a question as impossible when it reaches for its
|
|
2561
|
+
// native interaction first.
|
|
2562
|
+
instructions.push("- When user input is required, call the OpenCode `question` tool. Cursor-native AskQuestion requests are also accepted and answered through it.");
|
|
2563
|
+
}
|
|
2564
|
+
// CreatePlan is a Cursor InteractionQuery (#7), not an OpenCode/MCP catalog
|
|
2565
|
+
// tool. Always say so when we advertise any tools — otherwise the model sees
|
|
2566
|
+
// CreatePlan in Cursor's function definitions, notices it is absent from the
|
|
2567
|
+
// OpenCode list, and narrates "No CreatePlan MCP tool is available" even
|
|
2568
|
+
// though the provider bridges the native interaction.
|
|
2569
|
+
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
2570
|
if (names.has("plan_enter")) {
|
|
2056
|
-
instructions.push("- To enter plan mode, call the OpenCode `plan_enter` tool
|
|
2571
|
+
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
2572
|
}
|
|
2058
2573
|
else if (names.has("todowrite")) {
|
|
2059
|
-
instructions.push("- For planning, call the OpenCode `todowrite` tool and
|
|
2574
|
+
instructions.push("- For planning task lists, call the OpenCode `todowrite` tool and/or write the plan as normal markdown.");
|
|
2060
2575
|
}
|
|
2061
2576
|
if (names.has("plan_exit")) {
|
|
2062
|
-
instructions.push("- To leave plan mode, call the OpenCode `plan_exit` tool.");
|
|
2577
|
+
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
2578
|
}
|
|
2064
2579
|
if (names.has(CUSTOM_WEBSEARCH_TOOL)) {
|
|
2065
2580
|
instructions.push(`- For web searches, call \`${CUSTOM_WEBSEARCH_TOOL}\`; do not use Cursor's native WebSearch interaction.`);
|
|
@@ -2073,8 +2588,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2073
2588
|
if (names.has(CUSTOM_READ_MCP_RESOURCE_TOOL)) {
|
|
2074
2589
|
instructions.push(`- To read an MCP resource, call \`${CUSTOM_READ_MCP_RESOURCE_TOOL}\`; do not use Cursor's native resource-reading interaction.`);
|
|
2075
2590
|
}
|
|
2076
|
-
if (names.has("task")
|
|
2077
|
-
const target =
|
|
2591
|
+
if (names.has("task")) {
|
|
2592
|
+
const target = "`task`";
|
|
2078
2593
|
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
2079
2594
|
instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
|
|
2080
2595
|
"Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
|
|
@@ -2102,8 +2617,8 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
2102
2617
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
2103
2618
|
`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
2619
|
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
|
|
2620
|
+
? "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."
|
|
2621
|
+
: "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
2622
|
...(instructions.length > 0
|
|
2108
2623
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
2109
2624
|
: []),
|
|
@@ -2282,22 +2797,50 @@ export function spanEndParts(opts) {
|
|
|
2282
2797
|
out.push({ type: "text-end", id: opts.textId });
|
|
2283
2798
|
return out;
|
|
2284
2799
|
}
|
|
2800
|
+
function toolsInFixedOrder(tools) {
|
|
2801
|
+
return tools.map((tool) => ({ ...tool })).sort((left, right) => (left.name ?? "").localeCompare(right.name ?? ""));
|
|
2802
|
+
}
|
|
2285
2803
|
/** Exported for tests — false for compaction/summary (no tools) and toolChoice none. */
|
|
2286
2804
|
export function computeAllowTools(toolCount, toolChoice) {
|
|
2287
2805
|
return toolCount > 0 && toolChoice?.type !== "none";
|
|
2288
2806
|
}
|
|
2289
|
-
export function resolveTurnToolState(input) {
|
|
2807
|
+
export async function resolveTurnToolState(input) {
|
|
2290
2808
|
const { sessionKey, incomingTools, isCompaction } = input;
|
|
2291
2809
|
if (sessionKey && incomingTools.length > 0) {
|
|
2292
|
-
rememberToolCatalog(sessionKey, incomingTools
|
|
2810
|
+
rememberToolCatalog(sessionKey, toolsInFixedOrder(incomingTools));
|
|
2811
|
+
}
|
|
2812
|
+
// Advertisement and permission are deliberately independent.
|
|
2813
|
+
//
|
|
2814
|
+
// Advertisement must stay byte-stable across every Run of a conversation or
|
|
2815
|
+
// the RequestContext changes shape and Cursor's prompt cache goes cold. A
|
|
2816
|
+
// zero-tool call is never a smaller catalog — it is a lifecycle turn
|
|
2817
|
+
// (compaction, title generation, summarization) that OpenCode opens alongside
|
|
2818
|
+
// the real one — so it re-advertises the last real catalog rather than
|
|
2819
|
+
// collapsing the context to tools=0. A genuinely restricted turn still sends
|
|
2820
|
+
// a non-empty set and is advertised verbatim.
|
|
2821
|
+
//
|
|
2822
|
+
// On cold start the lifecycle Run may arrive before any catalog exists. For a
|
|
2823
|
+
// valid session key, wait until a sibling doStream publishes the first real
|
|
2824
|
+
// catalog; cancellation is the only escape. Never time out to tools=[] and
|
|
2825
|
+
// never invent or filter the enabled set.
|
|
2826
|
+
//
|
|
2827
|
+
// Permission is computed from what actually arrived, so those lifecycle turns
|
|
2828
|
+
// still refuse execution and cannot duplicate the real turn's side effects.
|
|
2829
|
+
let advertisedTools;
|
|
2830
|
+
if (incomingTools.length > 0) {
|
|
2831
|
+
advertisedTools = toolsInFixedOrder(incomingTools);
|
|
2832
|
+
}
|
|
2833
|
+
else if (sessionKey) {
|
|
2834
|
+
const cached = toolCatalogBySession.get(sessionKey)
|
|
2835
|
+
?? await waitForSiblingToolCatalog(sessionKey, input.abortSignal);
|
|
2836
|
+
advertisedTools = toolsInFixedOrder(cached);
|
|
2837
|
+
}
|
|
2838
|
+
else {
|
|
2839
|
+
// Without a session key there is no safe sibling-correlation key. Preserve
|
|
2840
|
+
// the host's literal no-tool call rather than borrowing another session's
|
|
2841
|
+
// catalog.
|
|
2842
|
+
advertisedTools = [];
|
|
2293
2843
|
}
|
|
2294
|
-
const cached = sessionKey ? toolCatalogBySession.get(sessionKey) : undefined;
|
|
2295
|
-
if (sessionKey && cached && incomingTools.length === 0) {
|
|
2296
|
-
rememberToolCatalog(sessionKey, cached);
|
|
2297
|
-
}
|
|
2298
|
-
const advertisedTools = isCompaction && incomingTools.length === 0
|
|
2299
|
-
? (cached?.map((tool) => ({ ...tool })) ?? [])
|
|
2300
|
-
: incomingTools;
|
|
2301
2844
|
return {
|
|
2302
2845
|
advertisedTools,
|
|
2303
2846
|
allowTools: !isCompaction && computeAllowTools(incomingTools.length, input.toolChoice),
|
|
@@ -2316,6 +2859,11 @@ export function resolveTurnConversationReset(input) {
|
|
|
2316
2859
|
return { reset: false };
|
|
2317
2860
|
}
|
|
2318
2861
|
export function resetTurnStateForTests() {
|
|
2862
|
+
for (const waiters of toolCatalogWaitersBySession.values()) {
|
|
2863
|
+
for (const waiter of waiters)
|
|
2864
|
+
waiter.cancel();
|
|
2865
|
+
}
|
|
2866
|
+
toolCatalogWaitersBySession.clear();
|
|
2319
2867
|
toolCatalogBySession.clear();
|
|
2320
2868
|
postCompactionRebaseBySession.clear();
|
|
2321
2869
|
}
|