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.
@@ -1,7 +1,9 @@
1
+ import fs from "node:fs";
1
2
  import { encodeMessage } from "./messages.js";
2
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
3
4
  import { trace } from "../debug.js";
4
5
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
6
+ import { BACKGROUND_SHELL_MARKER } from "../shell-timeout.js";
5
7
  // Exec variant field number whose reply is the server-initiated request_context
6
8
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
7
9
  // field number for every exec variant, so this is also the result field.
@@ -133,8 +135,13 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
133
135
  // glob_args on the exec channel — Cursor's EditToolCall is display-only, and
134
136
  // glob/edit from opencode are advertised as MCP tools and arrive as mcp_args.
135
137
  //
136
- // OpenCode has no `ls` / `delete` builtins: ls read (dirs are readable),
137
- // delete bash `rm`. Arg key remapping happens in mapCursorArgsToOpencode.
138
+ // Design note (F11): Cursor has native delete / background-shell tools; OpenCode
139
+ // does not. This provider intentionally remaps:
140
+ // - delete_args → bash `rm -f -- <quoted-path>`
141
+ // - background_shell_spawn_args → bash (original command; plugin wraps nohup)
142
+ // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
143
+ // background / detached children can outlive the OpenCode tool call; leftover
144
+ // process cleanup is left to the user / OS. Arg key remapping happens below.
138
145
  const cursorToolToOpencode = {
139
146
  read_args: "read",
140
147
  write_args: "write",
@@ -207,7 +214,6 @@ export function mapExecServerToToolName(execField) {
207
214
  export function mapToolNameToExecField(toolName) {
208
215
  return opencodeToolToCursor[toolName];
209
216
  }
210
- const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
211
217
  export function parseExecServerMessage(msg) {
212
218
  const id = msg.id;
213
219
  if (id === undefined)
@@ -229,13 +235,16 @@ export function parseExecServerMessage(msg) {
229
235
  if (!resultField)
230
236
  return undefined;
231
237
  const execId = msg.exec_id ?? "";
238
+ // F11: Cursor native background shell → OpenCode bash. Emit the original
239
+ // command for UI/storage; the classic plugin before-hook wraps with nohup.
240
+ // Residual: the child can keep running after this tool call completes.
232
241
  if (execVariant === "background_shell_spawn_args") {
233
242
  const raw = msg.background_shell_spawn_args ?? {};
234
243
  const command = str(raw.command);
235
244
  const workingDirectory = str(raw.working_directory) ?? "";
236
245
  const args = {};
237
246
  if (command)
238
- args.command = buildBackgroundShellCommand(command);
247
+ args.command = command;
239
248
  if (workingDirectory)
240
249
  args.workdir = workingDirectory;
241
250
  return {
@@ -244,7 +253,11 @@ export function parseExecServerMessage(msg) {
244
253
  toolName: "bash",
245
254
  args,
246
255
  resultField,
247
- resultMetadata: { command: command ?? "", working_directory: workingDirectory },
256
+ resultMetadata: {
257
+ background_shell_spawn: true,
258
+ command: command ?? "",
259
+ working_directory: workingDirectory,
260
+ },
248
261
  localError: raw.enable_write_shell_stdin_tool === true
249
262
  ? "Interactive background shells are not available through OpenCode's bash tool."
250
263
  : command
@@ -328,9 +341,16 @@ export function parseExecServerMessage(msg) {
328
341
  if (!toolName)
329
342
  return undefined;
330
343
  const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
344
+ const rawArgs = msg[execVariant] ?? {};
331
345
  const resultMetadata = execVariant === "shell_stream_args"
332
- ? shellStreamResultMetadata(msg[execVariant] ?? {})
333
- : undefined;
346
+ ? shellStreamResultMetadata(rawArgs)
347
+ : execVariant === "read_args"
348
+ ? {
349
+ path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
350
+ ...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
351
+ ...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
352
+ }
353
+ : undefined;
334
354
  if (resultMetadata
335
355
  && resultMetadata.timeout_behavior !== 2
336
356
  && typeof resultMetadata.timeout_ms === "number") {
@@ -390,7 +410,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
390
410
  const filePath = str(cleaned.path) ?? str(cleaned.filePath);
391
411
  return { toolName: "read", args: filePath ? { filePath } : {} };
392
412
  }
393
- // Native delete_args → bash rm (OpenCode has no delete builtin).
413
+ // F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
414
+ // we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
415
+ // as any other bash tool call.
394
416
  if (execVariant === "delete_args") {
395
417
  const target = str(cleaned.path) ?? str(cleaned.filePath);
396
418
  return {
@@ -522,20 +544,6 @@ function mapCursorSubagentTypeToOpenCode(subagentType) {
522
544
  function shellQuote(s) {
523
545
  return `'${s.replace(/'/g, `'\\''`)}'`;
524
546
  }
525
- /**
526
- * OpenCode's bash tool is foreground-only. Detach the requested command inside
527
- * that one foreground call and print a private marker containing the spawned
528
- * PID and log path. With stdin and all output redirected, the host shell can
529
- * return immediately instead of retaining OpenCode's tool pipe.
530
- */
531
- function buildBackgroundShellCommand(command) {
532
- return [
533
- 'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
534
- `nohup sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
535
- "bg_pid=$!",
536
- `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
537
- ].join("\n");
538
- }
539
547
  /**
540
548
  * Map Cursor McpArgs back to the OpenCode tool id.
541
549
  * Prefers provider_identifier + bare tool_name (github + create_pull_request
@@ -646,7 +654,7 @@ export function buildExecClientMessages(input) {
646
654
  id: input.execId,
647
655
  local_execution_time_ms: input.executionTimeMs ?? 0,
648
656
  };
649
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata);
657
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome);
650
658
  frames.push(encodeMessage("AgentClientMessage", {
651
659
  exec_client_message: clientMsg,
652
660
  }));
@@ -733,22 +741,31 @@ export function unwrapReadOutput(output) {
733
741
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
734
742
  * server accepts (verified against agent.v1 wire captures).
735
743
  */
736
- export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
744
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome) {
737
745
  switch (resultField) {
738
- case "read_result":
746
+ case "read_result": {
747
+ const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
739
748
  if (error)
740
- return { error: { path: "", error } };
749
+ return { error: { path: readPath, error } };
741
750
  // Strip opencode's <path>/<content> envelope so Cursor's model receives
742
751
  // raw file content and can't echo the wrapper into subsequent writes.
743
752
  // reads route here only for native read_args; most arrive via mcp_result.
744
753
  const content = unwrapReadOutput(output);
754
+ const statPath = extractPathTag(output) ?? readPath;
755
+ const outputMetadata = parseOpenCodeReadMetadata(output);
756
+ const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
757
+ const rangeApplied = readRangeApplied(resultMetadata, totalLines);
745
758
  return {
746
759
  success: {
747
- path: extractPathTag(output) ?? "",
760
+ path: readPath,
748
761
  content,
749
- total_lines: countLines(content),
762
+ total_lines: totalLines,
763
+ file_size: readFileSize(statPath),
764
+ truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
765
+ range_applied: rangeApplied,
750
766
  },
751
767
  };
768
+ }
752
769
  case "grep_result": {
753
770
  if (error)
754
771
  return { error: { error } };
@@ -809,6 +826,19 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
809
826
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
810
827
  if (error)
811
828
  return { error: { command, working_directory: workingDirectory, error } };
829
+ // Prefer the structured outcome captured by the plugin after-hook; markers
830
+ // are already stripped from the stored/rendered OpenCode output by then.
831
+ if (shellOutcome?.kind === "backgrounded") {
832
+ return {
833
+ success: {
834
+ shell_id: shellOutcome.shellId,
835
+ command: shellOutcome.command || command,
836
+ working_directory: shellOutcome.workingDirectory || workingDirectory,
837
+ pid: shellOutcome.pid,
838
+ },
839
+ };
840
+ }
841
+ // Fallback when no plugin hook ran: parse the private spawn marker inline.
812
842
  const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
813
843
  const pid = match ? Number(match[1]) : 0;
814
844
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
@@ -913,6 +943,93 @@ function extractPathTag(output) {
913
943
  const m = output.match(/<path>([^<]+)<\/path>/);
914
944
  return m?.[1];
915
945
  }
946
+ /** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
947
+ function parseOpenCodeReadMetadata(output) {
948
+ const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(output);
949
+ if (showing) {
950
+ return {
951
+ startLine: Number(showing[1]),
952
+ endLine: Number(showing[2]),
953
+ ...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
954
+ outputCapped: output.includes("(Output capped at "),
955
+ };
956
+ }
957
+ const complete = /\(End of file - total (\d+) lines?\)/.exec(output);
958
+ if (complete)
959
+ return { totalLines: Number(complete[1]) };
960
+ return {};
961
+ }
962
+ function readRangeApplied(resultMetadata, totalLines) {
963
+ const offset = num(resultMetadata?.offset);
964
+ const limit = num(resultMetadata?.limit);
965
+ if (offset === undefined && limit === undefined)
966
+ return false;
967
+ if (totalLines === 0)
968
+ return false;
969
+ const startLine = offset ?? 1;
970
+ return startLine < 0 || startLine <= totalLines;
971
+ }
972
+ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
973
+ if (outputMetadata.outputCapped)
974
+ return true;
975
+ const returnedEnd = outputMetadata.endLine;
976
+ if (returnedEnd === undefined || totalLines === 0)
977
+ return false;
978
+ const offset = num(resultMetadata?.offset);
979
+ const limit = num(resultMetadata?.limit);
980
+ const startLine = offset ?? 1;
981
+ if (startLine < 0)
982
+ return false;
983
+ const expectedEnd = limit === undefined
984
+ ? totalLines
985
+ : Math.min(totalLines, Math.max(1, startLine) + limit - 1);
986
+ return returnedEnd < expectedEnd;
987
+ }
988
+ function readFileSize(filePath) {
989
+ if (!filePath)
990
+ return 0;
991
+ try {
992
+ return fs.statSync(filePath).size;
993
+ }
994
+ catch {
995
+ return 0;
996
+ }
997
+ }
998
+ function readFileLineCount(filePath) {
999
+ if (!filePath)
1000
+ return undefined;
1001
+ let fd;
1002
+ try {
1003
+ fd = fs.openSync(filePath, "r");
1004
+ const buffer = Buffer.allocUnsafe(64 * 1024);
1005
+ let totalBytes = 0;
1006
+ let lines = 1;
1007
+ while (true) {
1008
+ const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
1009
+ if (bytesRead === 0)
1010
+ break;
1011
+ totalBytes += bytesRead;
1012
+ for (let index = 0; index < bytesRead; index++) {
1013
+ if (buffer[index] === 0x0a)
1014
+ lines++;
1015
+ }
1016
+ }
1017
+ return totalBytes === 0 ? 0 : lines;
1018
+ }
1019
+ catch {
1020
+ return undefined;
1021
+ }
1022
+ finally {
1023
+ if (fd !== undefined) {
1024
+ try {
1025
+ fs.closeSync(fd);
1026
+ }
1027
+ catch {
1028
+ /* best effort */
1029
+ }
1030
+ }
1031
+ }
1032
+ }
916
1033
  function extractPathLines(output) {
917
1034
  const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
918
1035
  // Prefer absolute / relative path-looking lines; fall back to all non-empty.
package/dist/session.d.ts CHANGED
@@ -98,6 +98,8 @@ export type CursorSession = {
98
98
  * conversation_checkpoint_update (CLI parity).
99
99
  */
100
100
  conversationId: string;
101
+ /** Latest eligible checkpoint emitted by this Run attempt. */
102
+ resumeCheckpoint?: Uint8Array;
101
103
  /** OpenCode session whose own or descendant activity renews tool leases. */
102
104
  openCodeSessionId?: string;
103
105
  stream: BidiStream;
@@ -1,12 +1,16 @@
1
1
  /** Cursor agent.v1 TimeoutBehavior enum values. */
2
2
  export declare const CURSOR_TIMEOUT_CANCEL = 1;
3
3
  export declare const CURSOR_TIMEOUT_BACKGROUND = 2;
4
+ /** Private marker for Cursor `background_shell_spawn_args` detach wrappers. */
5
+ export declare const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
4
6
  export type CursorShellPolicy = {
5
7
  command: string;
6
8
  workingDirectory: string;
7
9
  timeoutMs: number;
8
10
  timeoutBehavior: number;
9
11
  hardTimeoutMs?: number;
12
+ /** Immediate nohup detach for Cursor `background_shell_spawn_args`. */
13
+ backgroundSpawn?: boolean;
10
14
  };
11
15
  export type CursorShellOutcome = {
12
16
  kind: "exit";
@@ -27,15 +31,60 @@ export declare function shellPolicyFromMetadata(metadata: Record<string, unknown
27
31
  /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
28
32
  export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
29
33
  /**
34
+ * F11 / soft-background helper.
35
+ *
30
36
  * Run a Cursor soft-background command for its foreground window, then leave
31
- * it detached if still alive. The sentinel is removed by the after hook before
32
- * OpenCode stores/renders the result.
37
+ * it detached (`nohup`) if still alive. The sentinel is removed by the after
38
+ * hook before OpenCode stores/renders the result.
39
+ *
40
+ * This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
41
+ * foreground-only bash tool. Residual: after OpenCode returns, the child (and
42
+ * optional hard-timeout watchdog) may still be running; this provider does not
43
+ * reap leftover processes — cleanup is left to the user / OS.
33
44
  */
34
45
  export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
35
- /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
46
+ /**
47
+ * F11 / background_shell_spawn_args helper.
48
+ *
49
+ * OpenCode's bash tool is foreground-only. Detach the requested command inside
50
+ * that one foreground call (`nohup … &`) and print a private marker containing
51
+ * the spawned PID and log path. With stdin and all output redirected, the host
52
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
53
+ *
54
+ * Residual: the detached child is not reaped by this provider after OpenCode
55
+ * completes the tool call; cleanup is left to the user / OS.
56
+ */
57
+ export declare function buildBackgroundShellCommand(command: string): string;
58
+ /**
59
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
60
+ *
61
+ * Important: do **not** replace `args.command` with the wrapper script.
62
+ * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
63
+ * persists the execute-time `args` object into that field. Mutating
64
+ * `args.command` therefore leaks the private wrapper into the TUI/GUI.
65
+ *
66
+ * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
67
+ * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
68
+ * replaced with the wrapper while the stored/displayed command stays original.
69
+ * Permissions also keep analyzing the real user command.
70
+ */
36
71
  export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
37
72
  /** Restore the model-facing command in OpenCode's completed tool title. */
38
73
  export declare function cursorShellOriginalCommand(toolCallId: string): string | undefined;
74
+ /** Drop injector temp files for a finished/abandoned Cursor shell call. */
75
+ export declare function releaseCursorShellEnv(toolCallId: string): void;
76
+ /**
77
+ * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
78
+ * wrapper while `args.command` (and therefore the bash UI) stay original.
79
+ */
80
+ export declare function cursorShellEnvForCall(toolCallId: string | undefined): Record<string, string> | undefined;
81
+ /**
82
+ * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
83
+ * Does not record outcomes — use {@link captureCursorShellResult} for that.
84
+ */
85
+ export declare function sanitizeCursorShellDisplayOutput(output: string, policy?: CursorShellPolicy): string;
86
+ /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
87
+ export declare function sanitizeRegisteredCursorShellOutput(toolCallId: string, output: string): string;
39
88
  /**
40
89
  * Capture Bash completion in the classic plugin's after hook. Returns the
41
90
  * sanitized output that OpenCode should store and render.