cursor-opencode-provider 0.6.4 → 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,11 @@ 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, binaryWritePayload, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, CUSTOM_LIST_MCP_RESOURCES_TOOL, CUSTOM_READ_MCP_RESOURCE_TOOL, } from "./protocol/tools.js";
10
- import { describeCursorExecVariant } from "./protocol/exec-variants.js";
9
+ import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, buildUnsupportedExecDeny, 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
+ import { buildGitDiffExecMessages } from "./protocol/git-diff.js";
11
+ import { cursorExecVariantByRequestField, describeCursorExecVariant } from "./protocol/exec-variants.js";
12
+ import { appendWorkspaceRootGrounding } from "./protocol/workspace-grounding.js";
13
+ import { progressOnlyContinuationPrompt, shouldContinueProgressOnlyTurn, } from "./protocol/progress-continuation.js";
11
14
  import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
12
15
  import { handleKvServerMessage } from "./protocol/kv.js";
13
16
  import { buildAskQuestionInteractionReply, buildAsyncAskQuestionCompletion, buildCreatePlanInteractionReply, buildSwitchModeInteractionReply, handleInteractionQuery, } from "./protocol/interactions.js";
@@ -39,7 +42,7 @@ import { resolveCursorModelSupportsImages } from "./model-metadata.js";
39
42
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
40
43
  import { analyzeReplayFrame, AttemptReplaySafety } from "./replay-safety.js";
41
44
  import { readAllFieldsStrict } from "./protocol/struct.js";
42
- import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, turnEndedCounter, } from "./usage.js";
45
+ import { buildLanguageModelV3UsageFromCounters, cursorUsageCountersFromTurnEnded, emptyLanguageModelV3Usage, formatCursorCacheDiagnostics, formatCursorTokenCategories, formatTurnUsageValidation, occupancyUsageFromTokenDetails, OPENCODE_DISPLAY_ONLY_COST_METADATA, turnEndedCounter, } from "./usage.js";
43
46
  let _availableModels;
44
47
  // mtime of the cache file the last time we loaded it. Compared on each call
45
48
  // so discoverModels' background refresh is picked up without a process restart.
@@ -578,6 +581,64 @@ export async function pumpWithRecovery(input) {
578
581
  }
579
582
  }
580
583
  }
584
+ const heartbeatWritePendingBySession = new WeakMap();
585
+ const heartbeatGenerationBySession = new WeakMap();
586
+ function bumpHeartbeatGeneration(session) {
587
+ const generation = (heartbeatGenerationBySession.get(session) ?? 0) + 1;
588
+ heartbeatGenerationBySession.set(session, generation);
589
+ heartbeatWritePendingBySession.set(session, false);
590
+ return generation;
591
+ }
592
+ function isCurrentHeartbeatGeneration(session, generation) {
593
+ return heartbeatGenerationBySession.get(session) === generation;
594
+ }
595
+ /** Start (or replace) the per-session heartbeat. In-flight writes from a prior attach are ignored. */
596
+ export function attachSessionHeartbeat(session) {
597
+ session.heartbeatCancel?.();
598
+ if (session.heartbeat) {
599
+ clearInterval(session.heartbeat);
600
+ session.heartbeat = null;
601
+ }
602
+ const generation = bumpHeartbeatGeneration(session);
603
+ const interval = setInterval(() => {
604
+ if (session.closed || !isCurrentHeartbeatGeneration(session, generation))
605
+ return;
606
+ if (heartbeatWritePendingBySession.get(session)) {
607
+ trace(`heartbeat skipped: prior heartbeat write is still pending sessionId=${session.sessionId}`);
608
+ return;
609
+ }
610
+ heartbeatWritePendingBySession.set(session, true);
611
+ const stream = session.stream;
612
+ void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
613
+ .then(() => {
614
+ if (!isCurrentHeartbeatGeneration(session, generation) || session.closed)
615
+ return;
616
+ sessionManager.recordHeartbeatWrite(session);
617
+ })
618
+ .catch((cause) => {
619
+ if (!isCurrentHeartbeatGeneration(session, generation) || session.closed)
620
+ return;
621
+ sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
622
+ replaySafe: false,
623
+ fallback: "Cursor heartbeat write failed",
624
+ }));
625
+ })
626
+ .finally(() => {
627
+ if (isCurrentHeartbeatGeneration(session, generation)) {
628
+ heartbeatWritePendingBySession.set(session, false);
629
+ }
630
+ });
631
+ }, session.policy.heartbeatMs);
632
+ interval.unref?.();
633
+ session.heartbeat = interval;
634
+ session.heartbeatCancel = () => {
635
+ if (session.heartbeat)
636
+ clearInterval(session.heartbeat);
637
+ session.heartbeat = null;
638
+ if (isCurrentHeartbeatGeneration(session, generation))
639
+ bumpHeartbeatGeneration(session);
640
+ };
641
+ }
581
642
  async function startSession(modelId, token, callOptions, options, startOptions) {
582
643
  const continuationPolicy = resolveContinuationPolicy(options.continuation);
583
644
  const prompt = callOptions.prompt;
@@ -941,32 +1002,61 @@ async function startSession(modelId, token, callOptions, options, startOptions)
941
1002
  closed: false,
942
1003
  };
943
1004
  sessionManager.registerSession(session);
944
- let heartbeatWritePending = false;
945
- session.heartbeat = setInterval(() => {
946
- if (session.closed)
947
- return;
948
- if (heartbeatWritePending) {
949
- // The pending write has its own bounded drain wait and will close the
950
- // session with the actual failing operation. Do not race it with another
951
- // heartbeat or misattribute an existing KV backlog to the heartbeat.
952
- trace(`heartbeat skipped: prior heartbeat write is still pending sessionId=${session.sessionId}`);
1005
+ attachSessionHeartbeat(session);
1006
+ const abortIfNeeded = (stream) => {
1007
+ if (!session.closed && !callOptions.abortSignal?.aborted)
953
1008
  return;
1009
+ try {
1010
+ stream?.destroy();
954
1011
  }
955
- heartbeatWritePending = true;
956
- void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
957
- .then(() => sessionManager.recordHeartbeatWrite(session))
958
- .catch((cause) => {
959
- sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
960
- replaySafe: false,
961
- fallback: "Cursor heartbeat write failed",
962
- }));
963
- })
964
- .finally(() => { heartbeatWritePending = false; });
965
- }, continuationPolicy.heartbeatMs);
966
- session.heartbeat.unref?.();
967
- session.heartbeatCancel = () => {
968
- if (session.heartbeat)
969
- clearInterval(session.heartbeat);
1012
+ catch { /* already closed */ }
1013
+ if (callOptions.abortSignal?.aborted) {
1014
+ throw new CursorLocalCancellationError("Cursor progress-only continuation cancelled");
1015
+ }
1016
+ throw new CursorProtocolError("Cannot reopen a closed Cursor session");
1017
+ };
1018
+ session.reopenWithUserMessage = async (text) => {
1019
+ abortIfNeeded();
1020
+ const { resolveBearerToken } = await import("./auth.js");
1021
+ const freshToken = await resolveBearerToken({
1022
+ accessToken: options.accessToken,
1023
+ apiKey: options.apiKey,
1024
+ baseUrl: resolveApiBaseURL(options),
1025
+ });
1026
+ abortIfNeeded();
1027
+ // Do not pass OpenCode's abortSignal into the h2 stream: a tool-calls abort
1028
+ // must not tear down a Run we still need to pump. Check abort around open.
1029
+ const next = await bidiRunStream(freshToken, { baseURL: agentBaseUrl, headers: options.headers });
1030
+ abortIfNeeded(next);
1031
+ const conversationState = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
1032
+ const reqBytes = buildRunRequest({
1033
+ text,
1034
+ modelId: cursorModelId,
1035
+ conversationId: session.conversationId,
1036
+ conversationGroupId: session.cacheDiagnostics?.conversationGroupId ?? session.conversationId,
1037
+ conversationState,
1038
+ parameterValues,
1039
+ maxMode,
1040
+ toolDescriptors: session.toolDescriptors,
1041
+ requestContext: session.requestContext,
1042
+ action: "user",
1043
+ });
1044
+ try {
1045
+ await writeWithBackpressure(next, reqBytes, "progress-only continuation Run");
1046
+ }
1047
+ catch (error) {
1048
+ try {
1049
+ next.destroy();
1050
+ }
1051
+ catch { }
1052
+ throw error;
1053
+ }
1054
+ abortIfNeeded(next);
1055
+ session.heartbeatCancel?.();
1056
+ await waitForStreamWrites(session.stream);
1057
+ abortIfNeeded(next);
1058
+ sessionManager.replaceStream(session, next);
1059
+ attachSessionHeartbeat(session);
970
1060
  };
971
1061
  callOptions.abortSignal?.addEventListener("abort", () => {
972
1062
  // Abort after tool-calls is normal — preserve pending sessions.
@@ -1172,6 +1262,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
1172
1262
  else if (!pending.bridged) {
1173
1263
  try {
1174
1264
  const shellResult = pending.resultField === "shell_stream"
1265
+ || pending.resultField === "shell_result"
1175
1266
  || pending.resultField === "background_shell_spawn_result"
1176
1267
  ? consumeCursorShellResult(r.toolCallId, r.output)
1177
1268
  : undefined;
@@ -1329,6 +1420,11 @@ function isTruthyEnv(value) {
1329
1420
  return value === "1" || value === "true";
1330
1421
  }
1331
1422
  const streamWriteChains = new WeakMap();
1423
+ async function waitForStreamWrites(stream) {
1424
+ const pending = streamWriteChains.get(stream);
1425
+ if (pending)
1426
+ await pending.catch(() => undefined);
1427
+ }
1332
1428
  /**
1333
1429
  * Keep awaited protocol writes ordered per Run stream. In particular, a large
1334
1430
  * KV get_blob reply must drain before another KV reply or heartbeat is queued;
@@ -1439,6 +1535,9 @@ export async function pump(session, controller, ids, abortSignal) {
1439
1535
  const advertisedToolNameSet = new Set(advertisedToolNames.map((name) => resolveCustomWebToolAlias(name, session.toolAliases)));
1440
1536
  let textStarted = false;
1441
1537
  let reasoningStarted = false;
1538
+ let assistantText = "";
1539
+ let progressContinuationAttempts = 0;
1540
+ let emittedHostTools = 0;
1442
1541
  const replaySafety = new AttemptReplaySafety(session.sessionId);
1443
1542
  const failRunProtocol = (message, code) => {
1444
1543
  replaySafety.markBarrier("unknown-or-malformed-frame");
@@ -1450,6 +1549,22 @@ export async function pump(session, controller, ids, abortSignal) {
1450
1549
  if (error instanceof CursorProviderError && error.origin !== "protocol")
1451
1550
  throw error;
1452
1551
  };
1552
+ const writeExecFrames = async (frames, operation) => {
1553
+ try {
1554
+ for (const frame of frames) {
1555
+ await writeWithBackpressure(session.stream, frame, operation);
1556
+ }
1557
+ return true;
1558
+ }
1559
+ catch (error) {
1560
+ rethrowTransportWriteFailure(error);
1561
+ const wrapped = new Error(`Failed to ${operation}: ${error.message}`);
1562
+ trace(`exec: reply FAILED ${wrapped.message}`);
1563
+ safeError(wrapped);
1564
+ sessionManager.close(session);
1565
+ return false;
1566
+ }
1567
+ };
1453
1568
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
1454
1569
  // in doStreamImpl). The frames iterator can still yield a final `done` after
1455
1570
  // the cancel lands — controller.enqueue on a cancelled controller throws.
@@ -1480,29 +1595,20 @@ export async function pump(session, controller, ids, abortSignal) {
1480
1595
  streamClosed = true;
1481
1596
  };
1482
1597
  /** Reply on Cursor's correlated exec channel without exposing a host tool call. */
1483
- const rejectExec = (parsed, reason, label) => {
1484
- try {
1485
- for (const frame of buildExecClientMessages({
1486
- execId: parsed.id,
1487
- resultField: parsed.resultField,
1488
- output: "",
1489
- error: reason,
1490
- toolName: parsed.toolName,
1491
- resultMetadata: parsed.resultMetadata,
1492
- workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
1493
- })) {
1494
- session.stream.write(frame);
1495
- }
1598
+ const rejectExec = async (parsed, reason, label) => {
1599
+ const groundedReason = appendWorkspaceRootGrounding(reason, workspaceRootFromRequestContext(session.requestContext));
1600
+ const ok = await writeExecFrames(buildExecClientMessages({
1601
+ execId: parsed.id,
1602
+ resultField: parsed.resultField,
1603
+ output: "",
1604
+ error: groundedReason,
1605
+ toolName: parsed.toolName,
1606
+ resultMetadata: parsed.resultMetadata,
1607
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
1608
+ }), `reject ${label} id=${parsed.id}`);
1609
+ if (ok)
1496
1610
  trace(`exec: REFUSED ${label} toolName=${parsed.toolName} id=${parsed.id}`);
1497
- return true;
1498
- }
1499
- catch (e) {
1500
- const error = new Error(`Failed to reject Cursor tool request (${label}): ${e.message}`);
1501
- trace(`exec: REFUSED reply FAILED ${error.message}`);
1502
- safeError(error);
1503
- sessionManager.close(session);
1504
- return false;
1505
- }
1611
+ return ok;
1506
1612
  };
1507
1613
  /**
1508
1614
  * Cursor's streamed edit handshake needs the complete target before it sends
@@ -1668,6 +1774,7 @@ export async function pump(session, controller, ids, abortSignal) {
1668
1774
  const emitText = (text) => {
1669
1775
  if (!text)
1670
1776
  return;
1777
+ assistantText += text;
1671
1778
  replaySafety.markBarrier("visible-text");
1672
1779
  // Close reasoning before text (hosts expect reasoning-end before text-start).
1673
1780
  if (reasoningStarted && !textStarted) {
@@ -1695,13 +1802,13 @@ export async function pump(session, controller, ids, abortSignal) {
1695
1802
  const emitFinish = (te, reason, settledUsage, settledSource) => {
1696
1803
  closeOpenSpans();
1697
1804
  const est = session.usageEstimate;
1698
- // OpenCode persists and sums every AI SDK finish. Cursor does not expose a
1699
- // final context total at tool boundaries, so charging the growing estimate
1700
- // there multiplies one held Run across all of its steps. Emit zero at
1701
- // intermediate boundaries, then prefer checkpoint tokenDetails exactly once
1702
- // when TurnEnded closes the held Run. Cursor CLI keeps context occupancy in
1703
- // agentStore tokenDetails; it does not reinterpret TurnEnded as occupancy.
1704
- const tokenDetails = te ? session.tokenDetails : undefined;
1805
+ // OpenCode TUI/GUI replace each assistant message's tokens (they do not
1806
+ // sum occupancy) and the TUI footer requires tokens.output > 0. Cost is
1807
+ // added per step-finish. Emit checkpoint occupancy snapshots at tool-call
1808
+ // boundaries with a $0 Copilot cost override, then the billed TurnEnded
1809
+ // snapshot once at stop. Char/4 usageEstimate stays traces-only.
1810
+ const tokenDetails = session.tokenDetails;
1811
+ const occupancyDetails = !te && tokenDetails && tokenDetails.usedTokens > 0 ? tokenDetails : undefined;
1705
1812
  const contextSource = tokenDetails
1706
1813
  ? session.tokenDetailsFresh
1707
1814
  ? "checkpoint-current-run"
@@ -1715,15 +1822,29 @@ export async function pump(session, controller, ids, abortSignal) {
1715
1822
  priorContextTokens: session.cacheDiagnostics?.priorTokenDetails?.usedTokens,
1716
1823
  })
1717
1824
  : emptyLanguageModelV3Usage()
1718
- : emptyLanguageModelV3Usage());
1825
+ : occupancyDetails
1826
+ ? occupancyUsageFromTokenDetails(occupancyDetails, session.cacheDiagnostics?.priorTokenDetails)
1827
+ : emptyLanguageModelV3Usage());
1719
1828
  const providerMetadata = te
1720
1829
  ? cursorTurnEndedProviderMetadata(te, tokenDetails, contextSource)
1721
- : undefined;
1830
+ : occupancyDetails && contextSource
1831
+ ? {
1832
+ ...OPENCODE_DISPLAY_ONLY_COST_METADATA,
1833
+ cursor: {
1834
+ usageVersion: 3,
1835
+ occupancyOnly: true,
1836
+ context: cursorContextUsageMetadata(occupancyDetails, contextSource),
1837
+ },
1838
+ }
1839
+ : undefined;
1722
1840
  const reasonLabel = typeof reason === "object" && reason && "unified" in reason
1723
1841
  ? String(reason.unified ?? "unknown")
1724
1842
  : String(reason);
1725
1843
  const inTotal = usage.inputTokens?.total ?? 0;
1726
1844
  const outTotal = usage.outputTokens?.total ?? 0;
1845
+ const occupancySource = occupancyDetails
1846
+ ? `occupancy-${contextSource ?? "unavailable"}`
1847
+ : "intermediate-zero";
1727
1848
  trace(`finish: reason=${reasonLabel} ` +
1728
1849
  `v3In=${inTotal} v3Out=${outTotal} ` +
1729
1850
  `v3CacheRead=${usage.inputTokens?.cacheRead ?? 0} v3CacheWrite=${usage.inputTokens?.cacheWrite ?? 0} ` +
@@ -1734,11 +1855,20 @@ export async function pump(session, controller, ids, abortSignal) {
1734
1855
  `rawCacheWrite=${te ? turnEndedCounter(te, "cache_write") : est.cacheWrite} ` +
1735
1856
  `source=${te
1736
1857
  ? settledSource ?? (contextSource ?? "unavailable")
1737
- : "intermediate-zero"}`);
1858
+ : occupancySource}`);
1738
1859
  if (counters) {
1739
1860
  trace(formatTurnUsageValidation(counters, usage, tokenDetails, contextSource));
1740
1861
  trace(formatCursorCacheDiagnostics(counters, tokenDetails, cacheDiagnostics.priorTokenDetails, cacheDiagnostics));
1741
1862
  }
1863
+ else if (occupancyDetails) {
1864
+ trace(formatTurnUsageValidation({
1865
+ inputTokens: occupancyDetails.usedTokens,
1866
+ outputTokens: 1,
1867
+ cacheRead: session.cacheDiagnostics?.priorTokenDetails?.usedTokens ?? 0,
1868
+ cacheWrite: 0,
1869
+ reasoningTokens: 0,
1870
+ }, usage, occupancyDetails, contextSource));
1871
+ }
1742
1872
  safeEnqueue({
1743
1873
  type: "finish",
1744
1874
  usage,
@@ -1906,6 +2036,31 @@ export async function pump(session, controller, ids, abortSignal) {
1906
2036
  `sessionKey=${session.openCodeSessionId}: ${String(error)}`);
1907
2037
  });
1908
2038
  }
2039
+ const checkpoint = session.resumeCheckpoint ?? getCheckpoint(session.conversationId);
2040
+ if (typeof session.reopenWithUserMessage === "function"
2041
+ && checkpoint
2042
+ && checkpoint.length > 0
2043
+ && shouldContinueProgressOnlyTurn({
2044
+ allowTools: session.allowTools,
2045
+ advertisedToolCount: advertisedToolNames.length,
2046
+ assistantText,
2047
+ emittedHostTools,
2048
+ continuationAttempts: progressContinuationAttempts,
2049
+ pendingExecs: session.pending.size,
2050
+ })) {
2051
+ progressContinuationAttempts += 1;
2052
+ trace("progress-only: continuing attempt=1");
2053
+ try {
2054
+ await session.reopenWithUserMessage(progressOnlyContinuationPrompt(workspaceRootFromRequestContext(session.requestContext)));
2055
+ assistantText = "";
2056
+ continue;
2057
+ }
2058
+ catch (error) {
2059
+ // Cursor already completed this turn. A failed nudge must not discard
2060
+ // the valid turn_ended the user already received as assistant text.
2061
+ trace(`progress-only: continuation failed, finishing original turn: ${error.message}`);
2062
+ }
2063
+ }
1909
2064
  emitFinish(turnEnded, { unified: "stop", raw: undefined });
1910
2065
  sessionManager.close(session);
1911
2066
  return;
@@ -1985,6 +2140,7 @@ export async function pump(session, controller, ids, abortSignal) {
1985
2140
  const input = JSON.stringify(bridged.args ?? {});
1986
2141
  trace(`display BRIDGED tool-call toolCallId=${toolCallId} toolName=${bridged.toolName} ` +
1987
2142
  `variant=${bridged.variant} callId=${callId} inputLen=${input.length}`);
2143
+ emittedHostTools++;
1988
2144
  closeOpenSpans();
1989
2145
  safeEnqueue({
1990
2146
  type: "tool-call",
@@ -2075,6 +2231,24 @@ export async function pump(session, controller, ids, abortSignal) {
2075
2231
  failRunProtocol("Cursor read_mcp_resource reply failed", RUN_REPLY_FAILED);
2076
2232
  }
2077
2233
  }
2234
+ else if (esm.git_diff_request) {
2235
+ const request = (esm.git_diff_request ?? {});
2236
+ try {
2237
+ const frames = await buildGitDiffExecMessages({
2238
+ execId: esmId,
2239
+ request,
2240
+ workspaceRoot: workspaceRootFromRequestContext(session.requestContext),
2241
+ });
2242
+ for (const frame of frames) {
2243
+ await writeWithBackpressure(session.stream, frame, `git_diff reply id=${esmId}`);
2244
+ }
2245
+ trace(`exec git_diff: replied id=${esmId}`);
2246
+ }
2247
+ catch (error) {
2248
+ rethrowTransportWriteFailure(error);
2249
+ failRunProtocol("Cursor git_diff reply failed", RUN_REPLY_FAILED);
2250
+ }
2251
+ }
2078
2252
  else {
2079
2253
  replaySafety.markBarrier("non-control-exec");
2080
2254
  const displayCallId = extractExecDisplayCallId(esm);
@@ -2112,7 +2286,7 @@ export async function pump(session, controller, ids, abortSignal) {
2112
2286
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
2113
2287
  if (parsed) {
2114
2288
  if (parsed.localError) {
2115
- if (!rejectExec(parsed, parsed.localError, "invalid mapping"))
2289
+ if (!await rejectExec(parsed, parsed.localError, "invalid mapping"))
2116
2290
  return;
2117
2291
  continue;
2118
2292
  }
@@ -2122,7 +2296,7 @@ export async function pump(session, controller, ids, abortSignal) {
2122
2296
  // pumping for text / turn_ended instead of emitting tool-call.
2123
2297
  if (!session.allowTools) {
2124
2298
  const reason = "Tool calls are not available during this turn (summary/compaction).";
2125
- if (!rejectExec(parsed, reason, "allowTools=false"))
2299
+ if (!await rejectExec(parsed, reason, "allowTools=false"))
2126
2300
  return;
2127
2301
  continue;
2128
2302
  }
@@ -2136,7 +2310,7 @@ export async function pump(session, controller, ids, abortSignal) {
2136
2310
  if (!advertisedToolNameSet.has(CURSOR_IMAGE_SAVE_TOOL)) {
2137
2311
  const reason = "This OpenCode agent cannot write binary file content. "
2138
2312
  + "Do not retry this write with the same bytes.";
2139
- if (!rejectExec(parsed, reason, "binary write unsupported"))
2313
+ if (!await rejectExec(parsed, reason, "binary write unsupported"))
2140
2314
  return;
2141
2315
  continue;
2142
2316
  }
@@ -2157,7 +2331,7 @@ export async function pump(session, controller, ids, abortSignal) {
2157
2331
  });
2158
2332
  }
2159
2333
  catch (error) {
2160
- if (!rejectExec(parsed, error.message, "binary write too large"))
2334
+ if (!await rejectExec(parsed, error.message, "binary write too large"))
2161
2335
  return;
2162
2336
  continue;
2163
2337
  }
@@ -2173,6 +2347,7 @@ export async function pump(session, controller, ids, abortSignal) {
2173
2347
  trace(`exec: binary write STAGED toolCallId=${toolCallId} ` +
2174
2348
  `requested=${JSON.stringify(binaryWrite.path)} target=${JSON.stringify(target)} ` +
2175
2349
  `bytes=${binaryWrite.data.length}`);
2350
+ emittedHostTools++;
2176
2351
  closeOpenSpans();
2177
2352
  safeEnqueue({
2178
2353
  type: "tool-call",
@@ -2198,7 +2373,7 @@ export async function pump(session, controller, ids, abortSignal) {
2198
2373
  `'${parsed.toolName}'.`;
2199
2374
  trace(`exec: unavailable catalog target toolName=${parsed.toolName} ` +
2200
2375
  `advertised=[${advertisedToolNames.join(",")}]`);
2201
- if (!rejectExec(parsed, reason, "unavailable tool"))
2376
+ if (!await rejectExec(parsed, reason, "unavailable tool"))
2202
2377
  return;
2203
2378
  continue;
2204
2379
  }
@@ -2215,6 +2390,7 @@ export async function pump(session, controller, ids, abortSignal) {
2215
2390
  }
2216
2391
  const tc = buildToolCallPart(parsed, session.sessionId);
2217
2392
  if (parsed.resultField === "shell_stream"
2393
+ || parsed.resultField === "shell_result"
2218
2394
  || parsed.resultField === "background_shell_spawn_result") {
2219
2395
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
2220
2396
  }
@@ -2222,6 +2398,7 @@ export async function pump(session, controller, ids, abortSignal) {
2222
2398
  sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
2223
2399
  // tc.input is already a JSON string (LanguageModelV3ToolCall.input).
2224
2400
  trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
2401
+ emittedHostTools++;
2225
2402
  // Close open text/reasoning spans before tool-call (required by AI SDK V3).
2226
2403
  closeOpenSpans();
2227
2404
  safeEnqueue({
@@ -2233,11 +2410,36 @@ export async function pump(session, controller, ids, abortSignal) {
2233
2410
  emitFinish(undefined, { unified: "tool-calls", raw: undefined });
2234
2411
  return;
2235
2412
  }
2413
+ // Known Cursor-native exec variants with no safe OpenCode bridge are
2414
+ // soft-denied with a populated typed result or throw, so the turn
2415
+ // continues with remaining tools. Unknown fields still hard-fail.
2416
+ const variantField = detectExecVariantField(payload);
2417
+ const variant = variantField !== undefined
2418
+ ? cursorExecVariantByRequestField(variantField)
2419
+ : undefined;
2420
+ if (variant?.handling === "unsupported") {
2421
+ const advertised = advertisedToolNames.length > 0 ? advertisedToolNames.join(", ") : "none";
2422
+ const rawReason = `Cursor-native '${variant.requestName}' is not available on this host. Continue with listed tools: ${advertised}; do not retry '${variant.requestName}'.`;
2423
+ const grounded = appendWorkspaceRootGrounding(rawReason, workspaceRootFromRequestContext(session.requestContext));
2424
+ // Allowlist/status denies have no string channel; buildUnsupportedExecDeny
2425
+ // ignores `reason` for those shapes and still selects the typed oneof.
2426
+ const frames = buildUnsupportedExecDeny({ execId: esmId, variant, reason: grounded });
2427
+ try {
2428
+ for (const frame of frames) {
2429
+ await writeWithBackpressure(session.stream, frame, `unsupported deny ${variant.requestName} id=${esmId}`);
2430
+ }
2431
+ }
2432
+ catch (error) {
2433
+ rethrowTransportWriteFailure(error);
2434
+ failRunProtocol("Cursor unsupported exec deny reply failed", RUN_REPLY_FAILED);
2435
+ }
2436
+ trace(`exec: SOFT-DENIED ${variant.requestName} id=${esmId}`);
2437
+ continue;
2438
+ }
2236
2439
  // Never guess a response type for an unknown exec variant. Request and
2237
2440
  // result field numbers are not universally identical; a structurally
2238
2441
  // wrong reply recreates the heartbeat-only deadlock. Fail promptly so
2239
2442
  // schema drift is actionable.
2240
- const variantField = detectExecVariantField(payload);
2241
2443
  const variantDescription = describeCursorExecVariant(variantField);
2242
2444
  const hex = Array.from(payload.subarray(0, 48))
2243
2445
  .map((x) => x.toString(16).padStart(2, "0"))
@@ -2313,6 +2515,7 @@ export async function pump(session, controller, ids, abortSignal) {
2313
2515
  trace(`interaction_query: BRIDGED ask_question id=${handled.id} toolCallId=${toolCallId} ` +
2314
2516
  `questions=${ask.args.questions.length} runAsync=${ask.args.runAsync} ` +
2315
2517
  `cursorToolCallId=${ask.toolCallId || "(none)"}`);
2518
+ emittedHostTools++;
2316
2519
  closeOpenSpans();
2317
2520
  safeEnqueue({
2318
2521
  type: "tool-call",
@@ -2359,6 +2562,7 @@ export async function pump(session, controller, ids, abortSignal) {
2359
2562
  `bridge=${bridgeKind} hostTool=${toolName} ` +
2360
2563
  `target=${JSON.stringify(sw.args.targetModeId)} ` +
2361
2564
  `cursorToolCallId=${sw.toolCallId || "(none)"}`);
2565
+ emittedHostTools++;
2362
2566
  closeOpenSpans();
2363
2567
  safeEnqueue({
2364
2568
  type: "tool-call",
@@ -2400,6 +2604,7 @@ export async function pump(session, controller, ids, abortSignal) {
2400
2604
  // before asking — approving a plan you cannot read is not approval.
2401
2605
  if (plan.planReview)
2402
2606
  emitText(plan.planReview);
2607
+ emittedHostTools++;
2403
2608
  closeOpenSpans();
2404
2609
  safeEnqueue({
2405
2610
  type: "tool-call",
@@ -2630,6 +2835,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
2630
2835
  : []),
2631
2836
  ...instructions,
2632
2837
  "Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
2838
+ "A progress update such as \"Checking…\", \"Inspecting…\", or \"Let me look…\" is not a final answer.",
2839
+ "After progress narration, call a listed tool in the same turn; if no tool is needed, provide the complete user-facing answer before finishing.",
2840
+ "Never end a turn with progress narration alone.",
2633
2841
  ].join("\n");
2634
2842
  }
2635
2843
  /** Rough char→token estimate for mid-turn usage before TurnEnded arrives. */
@@ -1,4 +1,4 @@
1
- import { type ModelInfo } from "./models.js";
1
+ import { type ModelInfo, type ModelVariant } from "./models.js";
2
2
  /**
3
3
  * Display names shared by both a thinking and a non-thinking model. A thinking
4
4
  * model with such a name needs a "Thinking" tag to disambiguate it from its
@@ -11,5 +11,7 @@ export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string
11
11
  export declare function modelInfoToConfig(mi: ModelInfo, options?: {
12
12
  thinkingSuffix?: boolean;
13
13
  contextTier?: "base" | "long";
14
+ variants?: ModelVariant[];
15
+ fast?: boolean;
14
16
  }): Record<string, any>;
15
17
  export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;