cursor-opencode-provider 0.6.4 → 0.6.7

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,7 +6,7 @@ import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./s
6
6
  import { buildEnv } from "../context/env.js";
7
7
  import { ensureOpencodeProjectDir } from "../context/paths.js";
8
8
  import { trace, traceRequestContextPaths } from "../debug.js";
9
- import { cursorExecVariantByRequestName } from "./exec-variants.js";
9
+ import { cursorExecVariantByRequestName, FORCE_BACKGROUND_STATUS_ERROR, } from "./exec-variants.js";
10
10
  import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
11
11
  import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
12
12
  // Exec variant field number whose reply is the server-initiated request_context
@@ -233,6 +233,8 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
233
233
  // does not. This provider intentionally remaps:
234
234
  // - delete_args → bash `rm -f -- <quoted-path>`
235
235
  // - background_shell_spawn_args → bash (original command; plugin wraps nohup)
236
+ // - shell_args (#2) → bash, same timeout/background path as shell_stream; the
237
+ // reply stays ShellResult (not the streaming oneof).
236
238
  // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
237
239
  // background / detached children can outlive the OpenCode tool call; leftover
238
240
  // process cleanup is left to the user / OS. Arg key remapping happens below.
@@ -249,6 +251,7 @@ const cursorToolToOpencode = {
249
251
  grep_args: "grep",
250
252
  ls_args: "read",
251
253
  delete_args: "bash",
254
+ shell_args: "bash",
252
255
  shell_stream_args: "bash",
253
256
  background_shell_spawn_args: "bash",
254
257
  subagent_args: "task",
@@ -793,7 +796,7 @@ export function parseExecServerMessage(msg) {
793
796
  "pi_read_args", "pi_bash_args", "pi_edit_args", "pi_write_args",
794
797
  "pi_grep_args", "pi_find_args", "pi_ls_args",
795
798
  "grep_args", "ls_args",
796
- "delete_args", "shell_stream_args", "background_shell_spawn_args", "mcp_args",
799
+ "delete_args", "shell_args", "shell_stream_args", "background_shell_spawn_args", "mcp_args",
797
800
  "subagent_args",
798
801
  ]);
799
802
  if (!execVariant)
@@ -915,7 +918,7 @@ export function parseExecServerMessage(msg) {
915
918
  return undefined;
916
919
  const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
917
920
  const rawArgs = msg[execVariant] ?? {};
918
- const resultMetadata = execVariant === "shell_stream_args"
921
+ const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
919
922
  ? shellStreamResultMetadata(rawArgs)
920
923
  : execVariant === "read_args"
921
924
  ? {
@@ -1412,6 +1415,164 @@ export function buildExecStreamClose(execId) {
1412
1415
  },
1413
1416
  });
1414
1417
  }
1418
+ export function buildUnsupportedExecDeny(input) {
1419
+ const { execId, variant, reason } = input;
1420
+ const resultName = variant.resultName;
1421
+ const frames = [];
1422
+ const throwFrame = (msg) => encodeMessage("AgentClientMessage", {
1423
+ exec_client_control_message: {
1424
+ throw: { id: execId, error: msg },
1425
+ },
1426
+ });
1427
+ switch (resultName) {
1428
+ case "shell_result":
1429
+ frames.push(encodeMessage("AgentClientMessage", {
1430
+ exec_client_message: {
1431
+ id: execId,
1432
+ local_execution_time_ms: 0,
1433
+ shell_result: { rejected: { reason } },
1434
+ },
1435
+ }));
1436
+ break;
1437
+ case "diagnostics_result":
1438
+ case "canvas_diagnostics_result":
1439
+ frames.push(encodeMessage("AgentClientMessage", {
1440
+ exec_client_message: {
1441
+ id: execId,
1442
+ local_execution_time_ms: 0,
1443
+ [resultName]: { error: { path: "", error: reason } },
1444
+ },
1445
+ }));
1446
+ break;
1447
+ case "fetch_result":
1448
+ frames.push(encodeMessage("AgentClientMessage", {
1449
+ exec_client_message: {
1450
+ id: execId,
1451
+ local_execution_time_ms: 0,
1452
+ fetch_result: { error: { url: "", error: reason } },
1453
+ },
1454
+ }));
1455
+ break;
1456
+ case "record_screen_result":
1457
+ frames.push(encodeMessage("AgentClientMessage", {
1458
+ exec_client_message: {
1459
+ id: execId,
1460
+ local_execution_time_ms: 0,
1461
+ record_screen_result: { failure: { error: reason } },
1462
+ },
1463
+ }));
1464
+ break;
1465
+ case "computer_use_result":
1466
+ case "write_shell_stdin_result":
1467
+ case "smart_mode_classifier_result":
1468
+ frames.push(encodeMessage("AgentClientMessage", {
1469
+ exec_client_message: {
1470
+ id: execId,
1471
+ local_execution_time_ms: 0,
1472
+ [resultName]: { error: { error: reason } },
1473
+ },
1474
+ }));
1475
+ break;
1476
+ case "redacted_read_result":
1477
+ frames.push(encodeMessage("AgentClientMessage", {
1478
+ exec_client_message: {
1479
+ id: execId,
1480
+ local_execution_time_ms: 0,
1481
+ redacted_read_result: { error: { path: "", error: reason } },
1482
+ },
1483
+ }));
1484
+ break;
1485
+ case "subagent_await_result":
1486
+ frames.push(encodeMessage("AgentClientMessage", {
1487
+ exec_client_message: {
1488
+ id: execId,
1489
+ local_execution_time_ms: 0,
1490
+ subagent_await_result: { error: { error: reason } },
1491
+ },
1492
+ }));
1493
+ break;
1494
+ case "shell_allowlist_precheck_result":
1495
+ case "mcp_allowlist_precheck_result":
1496
+ case "web_fetch_allowlist_precheck_result":
1497
+ // Typed precheck result is a bool, not a reason string. Encoding
1498
+ // `{allowlisted:false}` selects the ExecClientMessage oneof; protobufjs
1499
+ // writes field 1 as varint 0 so Cursor decodes the deny rather than an
1500
+ // empty/unspecified precheck.
1501
+ frames.push(encodeMessage("AgentClientMessage", {
1502
+ exec_client_message: {
1503
+ id: execId,
1504
+ local_execution_time_ms: 0,
1505
+ [resultName]: { allowlisted: false },
1506
+ },
1507
+ }));
1508
+ break;
1509
+ case "force_background_shell_result":
1510
+ case "force_background_subagent_result":
1511
+ frames.push(encodeMessage("AgentClientMessage", {
1512
+ exec_client_message: {
1513
+ id: execId,
1514
+ local_execution_time_ms: 0,
1515
+ [resultName]: { status: FORCE_BACKGROUND_STATUS_ERROR },
1516
+ },
1517
+ }));
1518
+ break;
1519
+ case "mini_swe_agent_bash_result":
1520
+ frames.push(encodeMessage("AgentClientMessage", {
1521
+ exec_client_message: {
1522
+ id: execId,
1523
+ local_execution_time_ms: 0,
1524
+ mini_swe_agent_bash_result: { rejected: { reason } },
1525
+ },
1526
+ }));
1527
+ break;
1528
+ case "conversation_search_result":
1529
+ // No local Cursor conversation index. Empty success is truthful and
1530
+ // avoids a retry loop; the model can continue with listed tools.
1531
+ frames.push(encodeMessage("AgentClientMessage", {
1532
+ exec_client_message: {
1533
+ id: execId,
1534
+ local_execution_time_ms: 0,
1535
+ conversation_search_result: {
1536
+ success: { hits: [], truncated: false, partial: false, rebuilding: false },
1537
+ },
1538
+ },
1539
+ }));
1540
+ break;
1541
+ case "agent_store_conflict_result":
1542
+ frames.push(encodeMessage("AgentClientMessage", {
1543
+ exec_client_message: {
1544
+ id: execId,
1545
+ local_execution_time_ms: 0,
1546
+ agent_store_conflict_result: { error: { error: reason } },
1547
+ },
1548
+ }));
1549
+ break;
1550
+ case "adopt_result":
1551
+ frames.push(encodeMessage("AgentClientMessage", {
1552
+ exec_client_message: {
1553
+ id: execId,
1554
+ local_execution_time_ms: 0,
1555
+ adopt_result: { error: reason },
1556
+ },
1557
+ }));
1558
+ break;
1559
+ case "execute_hook_result":
1560
+ // agent.proto has no populated error oneof we can emit;
1561
+ // Cursor CLI answers this through exec_client_control_message.throw.
1562
+ frames.push(throwFrame(reason));
1563
+ frames.push(buildExecStreamClose(execId));
1564
+ return frames;
1565
+ default:
1566
+ // Fallback for any future unsupported variant without a dedicated shape:
1567
+ // typed error when possible, else throw. Unknown shapes should hard-fail
1568
+ // at the pump, so this path is not expected for known unsupported rows.
1569
+ frames.push(throwFrame(reason));
1570
+ frames.push(buildExecStreamClose(execId));
1571
+ return frames;
1572
+ }
1573
+ frames.push(buildExecStreamClose(execId));
1574
+ return frames;
1575
+ }
1415
1576
  /**
1416
1577
  * Strip opencode's `read` envelope, leaving raw file content.
1417
1578
  *
@@ -1570,6 +1731,58 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
1570
1731
  const truncation = readTruncationMessage(output, content);
1571
1732
  return { success: { output: content, ...(truncation ? { truncation } : {}) } };
1572
1733
  }
1734
+ case "shell_result": {
1735
+ const command = str(resultMetadata?.command) ?? "";
1736
+ const workingDirectory = str(resultMetadata?.working_directory) ?? "";
1737
+ if (error) {
1738
+ return {
1739
+ failure: {
1740
+ command,
1741
+ working_directory: workingDirectory,
1742
+ exit_code: 1,
1743
+ stdout: output || "",
1744
+ stderr: error,
1745
+ aborted: false,
1746
+ },
1747
+ };
1748
+ }
1749
+ if (shellOutcome?.kind === "timeout") {
1750
+ return {
1751
+ timeout: {
1752
+ command,
1753
+ working_directory: workingDirectory,
1754
+ timeout_ms: shellOutcome.timeoutMs,
1755
+ },
1756
+ };
1757
+ }
1758
+ if (shellOutcome?.kind === "backgrounded") {
1759
+ return {
1760
+ success: {
1761
+ command: shellOutcome.command || command,
1762
+ working_directory: shellOutcome.workingDirectory || workingDirectory,
1763
+ exit_code: 0,
1764
+ stdout: output,
1765
+ shell_id: shellOutcome.shellId,
1766
+ pid: shellOutcome.pid,
1767
+ ms_to_wait: shellOutcome.msToWait,
1768
+ background_reason: shellOutcome.reason,
1769
+ },
1770
+ is_background: true,
1771
+ pid: shellOutcome.pid,
1772
+ };
1773
+ }
1774
+ const exitCode = shellOutcome?.kind === "exit"
1775
+ ? Math.max(0, Math.min(0xffff_ffff, shellOutcome.code))
1776
+ : 0;
1777
+ return {
1778
+ success: {
1779
+ command,
1780
+ working_directory: workingDirectory,
1781
+ exit_code: exitCode,
1782
+ stdout: output,
1783
+ },
1784
+ };
1785
+ }
1573
1786
  case "pi_bash_result":
1574
1787
  case "pi_edit_result":
1575
1788
  case "pi_grep_result":
@@ -0,0 +1 @@
1
+ export declare function appendWorkspaceRootGrounding(reason: string, workspaceRoot: string | undefined): string;
@@ -0,0 +1,8 @@
1
+ export function appendWorkspaceRootGrounding(reason, workspaceRoot) {
2
+ if (!workspaceRoot || !workspaceRoot.trim())
3
+ return reason;
4
+ if (reason.includes("Workspace root:"))
5
+ return reason;
6
+ return (`${reason}\nWorkspace root: ${JSON.stringify(workspaceRoot)}. ` +
7
+ "Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.");
8
+ }
package/dist/session.d.ts CHANGED
@@ -175,8 +175,9 @@ export type CursorSession = {
175
175
  */
176
176
  allowTools: boolean;
177
177
  /**
178
- * Best-effort held-Run activity counters used only for diagnostics. AI SDK
179
- * accounting comes from the request-local final TurnEnded, never this estimate.
178
+ * Best-effort held-Run activity counters used only for diagnostics. Displayed
179
+ * AI SDK usage comes from checkpoint occupancy (tool steps) and TurnEnded
180
+ * (stop); this estimate is never sent to OpenCode.
180
181
  */
181
182
  usageEstimate: {
182
183
  inputTokens: number;
@@ -207,6 +208,7 @@ export type CursorSession = {
207
208
  semanticDeadlineAt: number;
208
209
  closeError: CursorProviderError | null;
209
210
  closed: boolean;
211
+ reopenWithUserMessage?: (text: string) => Promise<void>;
210
212
  };
211
213
  type SessionManagerOptions = {
212
214
  now?: () => number;
@@ -255,6 +257,13 @@ export declare class SessionManager {
255
257
  beginPump(session: CursorSession, owner: symbol): void;
256
258
  isPumpOwner(session: CursorSession, owner: symbol): boolean;
257
259
  endPump(session: CursorSession, owner: symbol): boolean;
260
+ /**
261
+ * Swap the live bidi Run while a pump is still pulling frames.
262
+ * Callers must cancel the session heartbeat and wait for the old stream's
263
+ * write chain before this, so an in-flight heartbeat cannot close the session.
264
+ */
265
+ replaceStream(session: CursorSession, next: BidiStream): void;
266
+ private subscribeTerminal;
258
267
  private key;
259
268
  close(session: CursorSession, reason?: SessionCloseReason, error?: CursorProviderError): void;
260
269
  /**
package/dist/session.js CHANGED
@@ -118,11 +118,7 @@ export class SessionManager {
118
118
  this.byOpenCodeSessionId.set(session.openCodeSessionId, session);
119
119
  }
120
120
  this.enforceOpenSessionCap(session);
121
- const unsubscribe = session.stream.onTerminal?.((event) => this.onStreamTerminal(session, event)) ?? (() => { });
122
- if (session.closed)
123
- unsubscribe();
124
- else
125
- session.terminalUnsubscribe = unsubscribe;
121
+ this.subscribeTerminal(session);
126
122
  }
127
123
  isPumping(session) {
128
124
  return session.pumpOwner != null || session.pumpActive;
@@ -325,6 +321,39 @@ export class SessionManager {
325
321
  this.close(session, deferred);
326
322
  return true;
327
323
  }
324
+ /**
325
+ * Swap the live bidi Run while a pump is still pulling frames.
326
+ * Callers must cancel the session heartbeat and wait for the old stream's
327
+ * write chain before this, so an in-flight heartbeat cannot close the session.
328
+ */
329
+ replaceStream(session, next) {
330
+ if (session.closed)
331
+ throw new CursorProtocolError("Cannot replace stream on a closed Cursor session");
332
+ session.heartbeatCancel?.();
333
+ session.terminalUnsubscribe?.();
334
+ session.terminalUnsubscribe = null;
335
+ session.deferredTerminalReason = null;
336
+ const old = session.stream;
337
+ session.stream = next;
338
+ session.frames = next.frames()[Symbol.asyncIterator]();
339
+ this.subscribeTerminal(session);
340
+ try {
341
+ old.destroy();
342
+ }
343
+ catch { /* already closed */ }
344
+ }
345
+ subscribeTerminal(session) {
346
+ const boundStream = session.stream;
347
+ const unsubscribe = boundStream.onTerminal?.((event) => {
348
+ if (session.stream !== boundStream)
349
+ return;
350
+ this.onStreamTerminal(session, event);
351
+ }) ?? (() => { });
352
+ if (session.closed)
353
+ unsubscribe();
354
+ else
355
+ session.terminalUnsubscribe = unsubscribe;
356
+ }
328
357
  key(sessionId, execId) {
329
358
  return `${sessionId}:${execId}`;
330
359
  }
package/dist/usage.d.ts CHANGED
@@ -51,6 +51,20 @@ export declare function formatCursorCacheDiagnostics(counters: CursorUsageCounte
51
51
  export declare function formatTurnUsageValidation(counters: CursorUsageCounters, usage: LanguageModelV3Usage, tokenDetails?: CursorConversationTokenDetails, contextSource?: CursorContextUsageSource): string;
52
52
  /** OpenCode requires a usage object at every step boundary. */
53
53
  export declare function emptyLanguageModelV3Usage(): LanguageModelV3Usage;
54
+ /**
55
+ * OpenCode TUI/GUI replace each assistant message's `tokens` (they do not sum
56
+ * occupancy). The TUI footer picks the last assistant with `tokens.output > 0`.
57
+ * Session cost, however, adds every step-finish. Checkpoint occupancy is
58
+ * therefore sent as a snapshot with `output=1` so the footer accepts it, and
59
+ * callers attach {@link OPENCODE_DISPLAY_ONLY_COST_METADATA} so getUsage
60
+ * reports $0 instead of billing the snapshot as a new prompt.
61
+ */
62
+ export declare const OPENCODE_DISPLAY_ONLY_COST_METADATA: {
63
+ readonly copilot: {
64
+ readonly totalNanoAiu: 0;
65
+ };
66
+ };
67
+ export declare function occupancyUsageFromTokenDetails(details: CursorConversationTokenDetails, prior?: CursorConversationTokenDetails): LanguageModelV3Usage;
54
68
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
55
69
  export declare function flatUsageFromV3(usage: LanguageModelV3Usage): {
56
70
  inputTokens: number;
package/dist/usage.js CHANGED
@@ -229,6 +229,32 @@ export function emptyLanguageModelV3Usage() {
229
229
  reasoningTokens: 0,
230
230
  });
231
231
  }
232
+ /**
233
+ * OpenCode TUI/GUI replace each assistant message's `tokens` (they do not sum
234
+ * occupancy). The TUI footer picks the last assistant with `tokens.output > 0`.
235
+ * Session cost, however, adds every step-finish. Checkpoint occupancy is
236
+ * therefore sent as a snapshot with `output=1` so the footer accepts it, and
237
+ * callers attach {@link OPENCODE_DISPLAY_ONLY_COST_METADATA} so getUsage
238
+ * reports $0 instead of billing the snapshot as a new prompt.
239
+ */
240
+ export const OPENCODE_DISPLAY_ONLY_COST_METADATA = {
241
+ copilot: { totalNanoAiu: 0 },
242
+ };
243
+ export function occupancyUsageFromTokenDetails(details, prior) {
244
+ const used = Math.max(0, Math.trunc(details.usedTokens));
245
+ if (used <= 0)
246
+ return emptyLanguageModelV3Usage();
247
+ return buildLanguageModelV3UsageFromCounters({
248
+ inputTokens: used,
249
+ outputTokens: 1,
250
+ cacheRead: prior?.usedTokens ?? 0,
251
+ cacheWrite: 0,
252
+ reasoningTokens: 0,
253
+ }, {
254
+ contextTotalTokens: used,
255
+ priorContextTokens: prior?.usedTokens,
256
+ });
257
+ }
232
258
  /** Project nested V3 usage into the common flat AI-SDK counter shape. */
233
259
  export function flatUsageFromV3(usage) {
234
260
  const input = usage.inputTokens;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.6.4",
3
+ "version": "0.6.7",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",