cursor-opencode-provider 0.2.4 → 0.2.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.
@@ -288,7 +288,7 @@ async function doStreamImpl(modelId, options, callOptions) {
288
288
  // Cursor can continue instead of deadlocking.
289
289
  const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
290
290
  trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] — rebasing fresh Run`);
291
- session = await openSession({ recovery: true });
291
+ session = await openSession({ recovery: { kind: "rebase" } });
292
292
  }
293
293
  else {
294
294
  // Fresh turn (prompt ends with user/assistant text). Historical tool
@@ -323,7 +323,7 @@ async function doStreamImpl(modelId, options, callOptions) {
323
323
  abortSignal: callOptions.abortSignal,
324
324
  promptTokens,
325
325
  retryPolicy,
326
- recover: () => openSession({ recovery: true }),
326
+ recover: (recovery) => openSession({ recovery }),
327
327
  onSession: (next) => { activeSession = next; },
328
328
  });
329
329
  try {
@@ -361,6 +361,7 @@ export async function pumpWithRecovery(input) {
361
361
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
362
362
  };
363
363
  const maxRecoveries = retryPolicy.maxAttempts - 1;
364
+ const requestUsage = { outputChars: 0 };
364
365
  input.onSession?.(session);
365
366
  for (let attempt = 0;; attempt++) {
366
367
  const pumpedSession = session;
@@ -371,6 +372,7 @@ export async function pumpWithRecovery(input) {
371
372
  textId: crypto.randomUUID(),
372
373
  reasoningId: crypto.randomUUID(),
373
374
  promptTokens: input.promptTokens ?? 0,
375
+ requestUsage,
374
376
  }, input.abortSignal);
375
377
  return session;
376
378
  }
@@ -381,19 +383,26 @@ export async function pumpWithRecovery(input) {
381
383
  });
382
384
  if (!failure.transient)
383
385
  throw failure;
384
- if (!failure.replaySafe) {
386
+ const checkpoint = pumpedSession.resumeCheckpoint;
387
+ if (!failure.replaySafe && !checkpoint) {
385
388
  throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
386
389
  }
387
390
  if (attempt >= maxRecoveries) {
388
391
  throw new CursorRetryExhaustedError(attempt + 1, failure);
389
392
  }
390
393
  trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
391
- `err=${failure.message} — rebasing fresh Run`);
394
+ `err=${failure.message} — ${checkpoint ? `resuming ${checkpoint.length}B checkpoint` : "rebasing fresh Run"}`);
392
395
  sessionManager.close(pumpedSession, "remote-error", failure);
393
396
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
394
397
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
395
398
  await sleepForRetry(delayMs, input.abortSignal);
396
- session = await input.recover();
399
+ session = await input.recover(checkpoint
400
+ ? {
401
+ kind: "resume",
402
+ conversationId: pumpedSession.conversationId,
403
+ checkpoint: Uint8Array.from(checkpoint),
404
+ }
405
+ : { kind: "rebase" });
397
406
  input.onSession?.(session);
398
407
  }
399
408
  finally {
@@ -419,18 +428,21 @@ async function startSession(modelId, token, callOptions, options, startOptions)
419
428
  const tools = toolState.advertisedTools;
420
429
  const allowTools = toolState.allowTools;
421
430
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
422
- const recovery = startOptions?.recovery === true;
431
+ const recovery = startOptions?.recovery;
432
+ const resuming = recovery?.kind === "resume";
423
433
  // Compaction must not reuse the prior conversation; its first normal turn
424
434
  // must also rebase so the summary-agent checkpoint cannot replace the normal
425
435
  // system prompt and OpenCode's newly compacted history.
426
- const bound = bindConversationId(sessionKey, { reset: resetState.reset || recovery });
436
+ const bound = resuming
437
+ ? { conversationId: recovery.conversationId, reset: false, previousId: undefined }
438
+ : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
427
439
  const conversationId = bound.conversationId;
428
440
  if (bound.reset) {
429
- trace(`conversation reset: reason=${recovery ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
441
+ trace(`conversation reset: reason=${recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
430
442
  `sessionKey=${sessionKey ?? "(none)"} ` +
431
443
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
432
444
  }
433
- const userText = recovery
445
+ const userText = recovery?.kind === "rebase"
434
446
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
435
447
  : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
436
448
  const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
@@ -439,7 +451,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
439
451
  const systemPrompt = interactionGuidance
440
452
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
441
453
  : baseSystemPrompt;
442
- const history = extractPromptHistory(prompt, { preserveTrailingUser: recovery });
454
+ const history = extractPromptHistory(prompt, {
455
+ preserveTrailingUser: recovery?.kind === "rebase",
456
+ toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
457
+ });
443
458
  await loadAvailableModels();
444
459
  // Resolve the region-specific Run stream origin once per process (memoized
445
460
  // in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
@@ -482,7 +497,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
482
497
  : [];
483
498
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
484
499
  // After compaction reset there is no checkpoint — seed from OpenCode history.
485
- const conversationState = bound.reset ? undefined : getCheckpoint(conversationId);
500
+ const conversationState = resuming
501
+ ? recovery.checkpoint
502
+ : (bound.reset ? undefined : getCheckpoint(conversationId));
486
503
  const reqBytes = buildRunRequest({
487
504
  text: userText,
488
505
  modelId: cursorModelId,
@@ -495,6 +512,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
495
512
  tools,
496
513
  toolDescriptors,
497
514
  requestContext,
515
+ action: resuming ? "resume" : "user",
498
516
  });
499
517
  // Content hashes — Cursor content-addresses large payloads; logging these lets
500
518
  // us match a server get_blob_args.blob_id to what it wants served.
@@ -520,6 +538,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
520
538
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
521
539
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
522
540
  `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
541
+ `resume=${resuming} ` +
523
542
  `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
524
543
  if (hooksCtx)
525
544
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
@@ -538,6 +557,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
538
557
  const session = {
539
558
  sessionId: crypto.randomUUID(),
540
559
  conversationId,
560
+ resumeCheckpoint: undefined,
541
561
  openCodeSessionId: sessionKey,
542
562
  stream,
543
563
  frames: stream.frames()[Symbol.asyncIterator](),
@@ -643,6 +663,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
643
663
  if (!pending.bridged) {
644
664
  try {
645
665
  const shellResult = pending.resultField === "shell_stream"
666
+ || pending.resultField === "background_shell_spawn_result"
646
667
  ? consumeCursorShellResult(r.toolCallId, r.output)
647
668
  : undefined;
648
669
  frames = buildExecClientMessages({
@@ -792,7 +813,7 @@ export async function pump(session, controller, ids, abortSignal) {
792
813
  const advertisedToolNameSet = new Set(advertisedToolNames);
793
814
  let textStarted = false;
794
815
  let reasoningStarted = false;
795
- let emittedOutputChars = 0;
816
+ const requestUsage = ids.requestUsage ?? { outputChars: 0 };
796
817
  let replaySafe = true;
797
818
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
798
819
  // in doStreamImpl). The frames iterator can still yield a final `done` after
@@ -847,6 +868,56 @@ export async function pump(session, controller, ids, abortSignal) {
847
868
  return false;
848
869
  }
849
870
  };
871
+ /**
872
+ * Cursor's streamed edit handshake reads the target before it sends the
873
+ * replacement through write_args. For a new file that read naturally fails,
874
+ * which makes the model abandon the edit and fall back to a shell heredoc.
875
+ * Treat only a missing target correlated to an edit_tool_call as an empty
876
+ * file, allowing Cursor to continue to the ordinary OpenCode write call.
877
+ */
878
+ const recoverMissingEditRead = (parsed, displayCallId) => {
879
+ if (!displayCallId ||
880
+ parsed.resultField !== "read_result" ||
881
+ parsed.toolName !== "read" ||
882
+ !advertisedToolNameSet.has("write"))
883
+ return false;
884
+ const stored = session.displayToolCalls.get(displayCallId);
885
+ const display = parseDisplayToolCall(displayCallId, stored);
886
+ if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
887
+ return false;
888
+ const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
889
+ const editPath = typeof display.args.path === "string" ? display.args.path : "";
890
+ if (!requestedPath || !editPath)
891
+ return false;
892
+ const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
893
+ ? session.requestContext.workspace_project_dir
894
+ : process.cwd();
895
+ const resolvePath = (value) => path.resolve(workspaceRoot, value);
896
+ const absolutePath = resolvePath(requestedPath);
897
+ if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
898
+ return false;
899
+ try {
900
+ for (const frame of buildExecClientMessages({
901
+ execId: parsed.id,
902
+ resultField: parsed.resultField,
903
+ output: "",
904
+ toolName: parsed.toolName,
905
+ resultMetadata: { path: requestedPath },
906
+ })) {
907
+ session.stream.write(frame);
908
+ }
909
+ trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
910
+ `path=${JSON.stringify(requestedPath)}; awaiting write_args`);
911
+ return true;
912
+ }
913
+ catch (e) {
914
+ const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
915
+ trace(`exec: edit read recovery FAILED ${error.message}`);
916
+ safeError(error);
917
+ sessionManager.close(session);
918
+ return true;
919
+ }
920
+ };
850
921
  /** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
851
922
  const closeOpenSpans = () => {
852
923
  for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
@@ -870,7 +941,7 @@ export async function pump(session, controller, ids, abortSignal) {
870
941
  }
871
942
  session.usageEstimate.outputTokens += estimateTokens(text.length);
872
943
  if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
873
- emittedOutputChars += text.length;
944
+ requestUsage.outputChars += text.length;
874
945
  }
875
946
  };
876
947
  const emitReasoning = (text) => {
@@ -883,7 +954,7 @@ export async function pump(session, controller, ids, abortSignal) {
883
954
  }
884
955
  session.usageEstimate.outputTokens += estimateTokens(text.length);
885
956
  if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
886
- emittedOutputChars += text.length;
957
+ requestUsage.outputChars += text.length;
887
958
  }
888
959
  };
889
960
  const emitFinish = (te, reason) => {
@@ -906,7 +977,7 @@ export async function pump(session, controller, ids, abortSignal) {
906
977
  cacheWrite: 0,
907
978
  },
908
979
  outputTokens: {
909
- total: estimateTokens(emittedOutputChars),
980
+ total: estimateTokens(requestUsage.outputChars),
910
981
  text: undefined,
911
982
  reasoning: undefined,
912
983
  },
@@ -1044,6 +1115,7 @@ export async function pump(session, controller, ids, abortSignal) {
1044
1115
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1045
1116
  if (bytes && bytes.length > 0) {
1046
1117
  setCheckpoint(session.conversationId, bytes);
1118
+ session.resumeCheckpoint = Uint8Array.from(bytes);
1047
1119
  trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
1048
1120
  }
1049
1121
  }
@@ -1153,7 +1225,11 @@ export async function pump(session, controller, ids, abortSignal) {
1153
1225
  trace(`exec request_context: replied`);
1154
1226
  }
1155
1227
  catch (e) {
1156
- trace(`exec request_context: write FAILED ${e.message}`);
1228
+ const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
1229
+ trace(`exec request_context: write FAILED ${error.message}`);
1230
+ safeError(error);
1231
+ sessionManager.close(session);
1232
+ return;
1157
1233
  }
1158
1234
  }
1159
1235
  else if (esm.mcp_state_exec_args) {
@@ -1179,10 +1255,6 @@ export async function pump(session, controller, ids, abortSignal) {
1179
1255
  else {
1180
1256
  const parsed = parseExecServerMessage(esm);
1181
1257
  const displayCallId = extractExecDisplayCallId(esm);
1182
- if (displayCallId) {
1183
- session.displayToolCalls.delete(displayCallId);
1184
- trace(`exec: claimed display callId=${displayCallId}`);
1185
- }
1186
1258
  trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
1187
1259
  if (parsed) {
1188
1260
  if (parsed.localError) {
@@ -1219,8 +1291,15 @@ export async function pump(session, controller, ids, abortSignal) {
1219
1291
  return;
1220
1292
  continue;
1221
1293
  }
1294
+ if (recoverMissingEditRead(parsed, displayCallId))
1295
+ continue;
1296
+ if (displayCallId) {
1297
+ session.displayToolCalls.delete(displayCallId);
1298
+ trace(`exec: claimed display callId=${displayCallId}`);
1299
+ }
1222
1300
  const tc = buildToolCallPart(parsed, session.sessionId);
1223
- if (parsed.resultField === "shell_stream") {
1301
+ if (parsed.resultField === "shell_stream"
1302
+ || parsed.resultField === "background_shell_spawn_result") {
1224
1303
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
1225
1304
  }
1226
1305
  // Keep the stream open; the result arrives on the next doStream call.
@@ -1257,7 +1336,8 @@ export async function pump(session, controller, ids, abortSignal) {
1257
1336
  else if (interactionQuery) {
1258
1337
  // InteractionQuery is a must-reply channel, just like exec and KV. AI
1259
1338
  // SDK has no Cursor-specific UI callback, so answer immediately with the
1260
- // conservative headless policy from protocol/interactions.ts.
1339
+ // conservative headless policy from protocol/interactions.ts (including
1340
+ // F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
1261
1341
  try {
1262
1342
  const handled = handleInteractionQuery(interactionQuery, payload);
1263
1343
  session.stream.write(handled.reply);
@@ -1290,7 +1370,13 @@ export async function pump(session, controller, ids, abortSignal) {
1290
1370
  `found=${handled.found} echoed=${!!handled.echoed} ` +
1291
1371
  `sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
1292
1372
  }
1293
- catch { /* stream closed; pump will surface end */ }
1373
+ catch (e) {
1374
+ const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
1375
+ trace(`kv: write FAILED ${error.message}`);
1376
+ safeError(error);
1377
+ sessionManager.close(session);
1378
+ return;
1379
+ }
1294
1380
  }
1295
1381
  }
1296
1382
  }
@@ -1423,6 +1509,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
1423
1509
  if (names.has("webfetch")) {
1424
1510
  instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
1425
1511
  }
1512
+ if (names.has("write")) {
1513
+ instructions.push(names.has("edit")
1514
+ ? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
1515
+ : "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
1516
+ }
1426
1517
  return [
1427
1518
  `OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
1428
1519
  `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.`,
@@ -1469,13 +1560,24 @@ export function cursorTurnEndedProviderMetadata(te) {
1469
1560
  };
1470
1561
  }
1471
1562
  /**
1472
- * Prior prompt turns for a seed ConversationStateStructure after compaction
1473
- * reset. Drops the trailing user message (live action), but preserves tool
1474
- * result payloads as labeled assistant observations for Cursor's text history.
1563
+ * Prior prompt turns for a seed ConversationStateStructure. Tool results must
1564
+ * never be replayed as assistant-authored prose: that teaches the model to
1565
+ * counterfeit `Tool result (...)` text instead of emitting a real tool call.
1566
+ * Normal rebases omit old results; compaction can retain all results and
1567
+ * interrupted continuations retain only the trailing live result suffix as
1568
+ * explicit OpenCode-host observations.
1475
1569
  */
1476
1570
  export function extractPromptHistory(prompt, options) {
1477
1571
  const out = [];
1478
- for (const m of prompt) {
1572
+ const toolResults = options?.toolResults ?? "omit";
1573
+ let trailingToolStart = prompt.length;
1574
+ if (toolResults === "trailing") {
1575
+ while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
1576
+ trailingToolStart--;
1577
+ }
1578
+ }
1579
+ for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
1580
+ const m = prompt[messageIndex];
1479
1581
  if (m.role === "system") {
1480
1582
  if (typeof m.content === "string" && m.content.length > 0) {
1481
1583
  out.push({ role: "system", content: m.content });
@@ -1495,18 +1597,26 @@ export function extractPromptHistory(prompt, options) {
1495
1597
  continue;
1496
1598
  }
1497
1599
  if (m.role === "tool" && Array.isArray(m.content)) {
1600
+ if (toolResults === "omit" ||
1601
+ (toolResults === "trailing" && messageIndex < trailingToolStart))
1602
+ continue;
1498
1603
  const results = [];
1499
1604
  for (const part of m.content) {
1500
1605
  const p = part;
1501
1606
  if (p.type !== "tool-result")
1502
1607
  continue;
1503
1608
  const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
1609
+ const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
1504
1610
  const result = toolResultOutputToText(p.output);
1505
- const label = result.isError ? "Tool error" : "Tool result";
1506
- results.push(`${label} (${toolName}):\n${result.text}`);
1611
+ results.push(formatSeedToolObservation({
1612
+ toolName,
1613
+ toolCallId,
1614
+ output: result.text,
1615
+ isError: result.isError,
1616
+ }));
1507
1617
  }
1508
1618
  if (results.length > 0)
1509
- appendSeedHistory(out, "assistant", results.join("\n\n"));
1619
+ appendSeedHistory(out, "user", results.join("\n\n"));
1510
1620
  }
1511
1621
  }
1512
1622
  // Live user message is the Run action, not seed history.
@@ -1515,6 +1625,15 @@ export function extractPromptHistory(prompt, options) {
1515
1625
  }
1516
1626
  return out;
1517
1627
  }
1628
+ function formatSeedToolObservation(input) {
1629
+ const metadata = JSON.stringify({
1630
+ source: "opencode-tool",
1631
+ tool: input.toolName,
1632
+ callId: input.toolCallId,
1633
+ status: input.isError ? "error" : "completed",
1634
+ });
1635
+ return `OpenCode host observation ${metadata}:\n${input.output}`;
1636
+ }
1518
1637
  function extractAssistantHistoryText(msg) {
1519
1638
  const content = msg.content;
1520
1639
  if (typeof content === "string")
package/dist/plugin.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
- import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtPayload } from "./auth.js";
2
+ import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
3
  import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
4
4
  import { opencodeGlobalCacheDir } from "./context/paths.js";
5
5
  import { readStoredAuth } from "./context/auth-store.js";
6
6
  import { resolveAgentUrl } from "./agent-url.js";
7
- import { captureCursorShellResult, cursorShellOriginalCommand, prepareCursorShellArgs, } from "./shell-timeout.js";
7
+ import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, } from "./shell-timeout.js";
8
8
  import { sessionActivity } from "./activity.js";
9
9
  const MODULE_URL = new URL("./index.js", import.meta.url).href;
10
10
  /**
@@ -275,7 +275,7 @@ export async function CursorPlugin(input) {
275
275
  type: "oauth",
276
276
  access: newTokens.accessToken,
277
277
  refresh: newTokens.refreshToken,
278
- expires: decodeExpFromJwt(newTokens.accessToken),
278
+ expires: decodeJwtExpiryMs(newTokens.accessToken) ?? Date.now(),
279
279
  ...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
280
280
  ...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
281
281
  });
@@ -336,13 +336,35 @@ export async function CursorPlugin(input) {
336
336
  async "tool.execute.before"(hookInput, output) {
337
337
  if (hookInput.tool !== "bash")
338
338
  return;
339
+ // Keep args.command as the original display/permission command. Wrapping
340
+ // happens via shell.env (BASH_ENV / ZDOTDIR) so OpenCode's bash UI never
341
+ // stores or renders the private wrapper script.
339
342
  prepareCursorShellArgs(hookInput.callID, output.args);
340
343
  },
344
+ async "shell.env"(hookInput, output) {
345
+ const env = cursorShellEnvForCall(hookInput.callID);
346
+ if (!env)
347
+ return;
348
+ Object.assign(output.env, env);
349
+ },
341
350
  async "tool.execute.after"(hookInput, output) {
342
351
  if (hookInput.tool !== "bash")
343
352
  return;
344
- output.title = cursorShellOriginalCommand(hookInput.callID) ?? output.title;
345
- output.output = captureCursorShellResult(hookInput.callID, output.output, output.metadata);
353
+ try {
354
+ output.title = cursorShellOriginalCommand(hookInput.callID) ?? output.title;
355
+ output.output = captureCursorShellResult(hookInput.callID, output.output, output.metadata);
356
+ // OpenCode's bash GUI falls back to metadata.output when output is empty
357
+ // (`props.output || props.metadata.output`), so strip private markers there too.
358
+ if (output.metadata && typeof output.metadata === "object") {
359
+ const metadata = output.metadata;
360
+ if (typeof metadata.output === "string") {
361
+ metadata.output = sanitizeRegisteredCursorShellOutput(hookInput.callID, metadata.output);
362
+ }
363
+ }
364
+ }
365
+ finally {
366
+ releaseCursorShellEnv(hookInput.callID);
367
+ }
346
368
  },
347
369
  async "chat.params"(hookInput, output) {
348
370
  if (hookInput.model.providerID !== CURSOR_PROVIDER_ID)
@@ -397,7 +419,7 @@ export async function CursorPlugin(input) {
397
419
  provider: CURSOR_PROVIDER_ID,
398
420
  access: result.accessToken,
399
421
  refresh: result.refreshToken,
400
- expires: decodeExpFromJwt(result.accessToken),
422
+ expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
401
423
  };
402
424
  },
403
425
  };
@@ -469,9 +491,3 @@ export async function CursorPlugin(input) {
469
491
  },
470
492
  };
471
493
  }
472
- function decodeExpFromJwt(jwt) {
473
- const payload = decodeJwtPayload(jwt);
474
- if (payload && typeof payload.exp === "number")
475
- return payload.exp * 1000;
476
- return Date.now() + 3600_000;
477
- }
@@ -19,5 +19,12 @@ export declare class UnsupportedInteractionQueryError extends Error {
19
19
  * instead of accidentally restoring the heartbeat-only deadlock.
20
20
  */
21
21
  export declare function inspectInteractionQueryWire(agentServerPayload: Uint8Array): InteractionQueryWireInfo;
22
- /** Build the immediate typed response required by Cursor's Run RPC. */
22
+ /**
23
+ * Build the immediate typed response required by Cursor's Run RPC.
24
+ *
25
+ * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
26
+ * here with a conservative headless policy (reject UI-bound prompts; ack a few
27
+ * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
28
+ * CLI headless mode.
29
+ */
23
30
  export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array): HandledInteraction;
@@ -51,7 +51,14 @@ export function inspectInteractionQueryWire(agentServerPayload) {
51
51
  variantName: variantField === undefined ? undefined : variantNames[variantField],
52
52
  };
53
53
  }
54
- /** Build the immediate typed response required by Cursor's Run RPC. */
54
+ /**
55
+ * Build the immediate typed response required by Cursor's Run RPC.
56
+ *
57
+ * OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
58
+ * here with a conservative headless policy (reject UI-bound prompts; ack a few
59
+ * no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
60
+ * CLI headless mode.
61
+ */
55
62
  export function handleInteractionQuery(query, agentServerPayload) {
56
63
  const info = inspectInteractionQueryWire(agentServerPayload);
57
64
  if (info.id === undefined || info.variantField === undefined || !info.variantName) {
@@ -80,8 +87,14 @@ export function handleInteractionQuery(query, agentServerPayload) {
80
87
  outcome = "rejected";
81
88
  break;
82
89
  case 7:
90
+ // F14: create_plan_request_query auto-ack (Cursor CLI headless parity).
83
91
  // Cursor CLI's headless fallback acknowledges plan creation without a
84
- // client-side URI; the plan remains in conversation state/checkpoints.
92
+ // client-side URI (`success` + empty `plan_uri`); the plan remains in
93
+ // conversation state / checkpoints. This provider mirrors that reply so
94
+ // the Run RPC does not deadlock waiting for UI approval. Impact: Cursor
95
+ // may treat the plan as accepted without an OpenCode UI confirm; local
96
+ // tool execution is still gated by OpenCode permissions. Do not change
97
+ // this to reject/prompt without CLI parity evidence.
85
98
  response = { create_plan_request_response: { result: { success: {}, plan_uri: "" } } };
86
99
  outcome = "acknowledged";
87
100
  break;
@@ -357,8 +357,11 @@ export function createMessageTypes() {
357
357
  { id: 2, name: "content", type: "string" },
358
358
  { id: 3, name: "total_lines", type: "int32" },
359
359
  { id: 4, name: "file_size", type: "int64" },
360
+ { id: 5, name: "data", type: "bytes" },
360
361
  { id: 6, name: "truncated", type: "bool" },
361
- ]);
362
+ { id: 7, name: "output_blob_id", type: "bytes" },
363
+ { id: 8, name: "range_applied", type: "bool" },
364
+ ], [{ name: "output", fields: ["content", "data"] }]);
362
365
  addType(root, "ReadError", [
363
366
  { id: 1, name: "path", type: "string" },
364
367
  { id: 2, name: "error", type: "string" },
@@ -900,7 +903,9 @@ export function createMessageTypes() {
900
903
  { id: 2, name: "request_context", type: "RequestContext" },
901
904
  ]);
902
905
  addType(root, "ResumeAction", [
903
- { id: 1, name: "conversation_id", type: "string" },
906
+ // Cursor CLI sends an empty ResumeAction. Field #2 is available for a
907
+ // refreshed RequestContext; the conversation id belongs to AgentRunRequest.
908
+ { id: 2, name: "request_context", type: "RequestContext" },
904
909
  ]);
905
910
  addType(root, "CancelAction", [
906
911
  { id: 1, name: "conversation_id", type: "string" },
@@ -10,8 +10,8 @@ export type RunRequestInput = {
10
10
  systemPrompt?: string;
11
11
  /**
12
12
  * Prior chat turns for a seed ConversationStateStructure (no checkpoint).
13
- * Used after compaction reset so Cursor sees OpenCode's compacted history
14
- * instead of an empty conversation.
13
+ * Tool outputs, when required for compaction/recovery, are represented as
14
+ * user-role OpenCode host observations rather than assistant-authored prose.
15
15
  */
16
16
  history?: SeedHistoryMessage[];
17
17
  /**
@@ -32,6 +32,8 @@ export type RunRequestInput = {
32
32
  toolDescriptors?: Array<Record<string, unknown>>;
33
33
  /** Prebuilt RequestContext (OpenCode-sourced). */
34
34
  requestContext?: Record<string, unknown>;
35
+ /** Resume the supplied checkpoint instead of submitting another user turn. */
36
+ action?: "user" | "resume";
35
37
  };
36
38
  /**
37
39
  * Seed ConversationStateStructure for the first turn (no checkpoint yet).
@@ -54,9 +54,11 @@ export function buildRunRequest(input) {
54
54
  message_id: msgId,
55
55
  },
56
56
  };
57
- if (requestContext) {
57
+ if (requestContext)
58
58
  userMessageAction.request_context = requestContext;
59
- }
59
+ const action = input.action === "resume"
60
+ ? { resume_action: {} }
61
+ : { user_message_action: userMessageAction };
60
62
  const conversationState = input.conversationState && input.conversationState.length > 0
61
63
  ? input.conversationState
62
64
  : buildSeedConversationState({
@@ -65,9 +67,7 @@ export function buildRunRequest(input) {
65
67
  });
66
68
  const runRequest = {
67
69
  conversation_id: input.conversationId,
68
- action: {
69
- user_message_action: userMessageAction,
70
- },
70
+ action,
71
71
  requested_model: {
72
72
  // The provider always selects a concrete model. Cursor's "default"
73
73
  // pseudo-model (Auto) is never used here — we send the real id plus the
@@ -7,7 +7,12 @@ const TODO_STATUS = {
7
7
  3: "completed",
8
8
  4: "cancelled",
9
9
  };
10
- /** Cursor ToolCall oneof field → default OpenCode tool id. */
10
+ /**
11
+ * Cursor ToolCall oneof field → default OpenCode tool id.
12
+ *
13
+ * F11: `delete_tool_call` has no OpenCode builtin; it remaps to bash (see the
14
+ * delete_tool_call branch below for `rm -f -- <path>`).
15
+ */
11
16
  const VARIANT_TO_OPENCODE = {
12
17
  shell_tool_call: "bash",
13
18
  delete_tool_call: "bash",
@@ -121,6 +126,10 @@ export function parseDisplayToolCall(callId, toolCall) {
121
126
  args: mcpArgs,
122
127
  };
123
128
  }
129
+ // create_plan_tool_call here is display-state mirroring into todowrite.
130
+ // Separately, InteractionQuery create_plan_request_query is auto-acked in
131
+ // protocol/interactions.ts (F14 / CLI headless parity) — that ack is not an
132
+ // execution of this display payload.
124
133
  if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
125
134
  const result = asRecord(payload.result);
126
135
  const success = asRecord(result?.success);
@@ -238,6 +247,7 @@ export function parseDisplayToolCall(callId, toolCall) {
238
247
  args: {},
239
248
  };
240
249
  }
250
+ // F11: display-path delete → bash `rm -f` (OpenCode has no delete tool).
241
251
  if (variant === "delete_tool_call") {
242
252
  const path = typeof args.path === "string" ? args.path : "";
243
253
  return {
@@ -1,4 +1,4 @@
1
- import type { CursorShellOutcome } from "../shell-timeout.js";
1
+ import { type CursorShellOutcome } from "../shell-timeout.js";
2
2
  export declare const REQUEST_CONTEXT_RESULT_FIELD = 10;
3
3
  export type OpencodeToolDef = {
4
4
  name: string;
@@ -128,7 +128,7 @@ export declare function unwrapReadOutput(output: string): string;
128
128
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
129
129
  * server accepts (verified against agent.v1 wire captures).
130
130
  */
131
- export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>): Record<string, unknown>;
131
+ export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>, shellOutcome?: CursorShellOutcome): Record<string, unknown>;
132
132
  export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
133
133
  toolCallId: string;
134
134
  toolName: string;