cursor-opencode-provider 0.2.5 → 0.2.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.
@@ -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";
@@ -23,6 +27,13 @@ export type CursorShellOutcome = {
23
27
  msToWait: number;
24
28
  reason: 1;
25
29
  };
30
+ /** Track OpenCode's configured shell from the classic config hook. */
31
+ export declare function setCursorShellPath(shell: string | undefined): void;
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export declare function resolveCursorShellKind(shell?: string | undefined): "bash" | "zsh" | "sh" | "dash" | "other";
26
37
  export declare function shellPolicyFromMetadata(metadata: Record<string, unknown> | undefined): CursorShellPolicy | undefined;
27
38
  /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
28
39
  export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
@@ -39,10 +50,46 @@ export declare function registerCursorShellCall(toolCallId: string, metadata: Re
39
50
  * reap leftover processes — cleanup is left to the user / OS.
40
51
  */
41
52
  export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
42
- /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
53
+ /**
54
+ * F11 / background_shell_spawn_args helper.
55
+ *
56
+ * OpenCode's bash tool is foreground-only. Detach the requested command inside
57
+ * that one foreground call (`nohup … &`) and print a private marker containing
58
+ * the spawned PID and log path. With stdin and all output redirected, the host
59
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
60
+ *
61
+ * Residual: the detached child is not reaped by this provider after OpenCode
62
+ * completes the tool call; cleanup is left to the user / OS.
63
+ */
64
+ export declare function buildBackgroundShellCommand(command: string): string;
65
+ /**
66
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
67
+ *
68
+ * bash/zsh source the shell.env injector, so the original command remains in
69
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
70
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
71
+ *
72
+ * background_shell_spawn may already contain the inline non-plugin fallback.
73
+ * The classic hook replaces it with the original command (bash/zsh) or the
74
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
75
+ */
43
76
  export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
44
77
  /** Restore the model-facing command in OpenCode's completed tool title. */
45
78
  export declare function cursorShellOriginalCommand(toolCallId: string): string | undefined;
79
+ /** Drop injector temp files for a finished/abandoned Cursor shell call. */
80
+ export declare function releaseCursorShellEnv(toolCallId: string): void;
81
+ /**
82
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
83
+ * same materialized wrapper backs the direct-command sh/dash fallback.
84
+ */
85
+ export declare function cursorShellEnvForCall(toolCallId: string | undefined): Record<string, string> | undefined;
86
+ /**
87
+ * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
88
+ * Does not record outcomes — use {@link captureCursorShellResult} for that.
89
+ */
90
+ export declare function sanitizeCursorShellDisplayOutput(output: string, policy?: CursorShellPolicy): string;
91
+ /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
92
+ export declare function sanitizeRegisteredCursorShellOutput(toolCallId: string, output: string): string;
46
93
  /**
47
94
  * Capture Bash completion in the classic plugin's after hook. Returns the
48
95
  * sanitized output that OpenCode should store and render.
@@ -1,3 +1,6 @@
1
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, delimiter, join } from "node:path";
1
4
  /** Cursor agent.v1 TimeoutBehavior enum values. */
2
5
  export const CURSOR_TIMEOUT_CANCEL = 1;
3
6
  export const CURSOR_TIMEOUT_BACKGROUND = 2;
@@ -7,16 +10,49 @@ const POLL_INTERVAL_MS = 100;
7
10
  const BACKGROUND_MARKER = "__CURSOR_SHELL_BACKGROUND__";
8
11
  const EXIT_MARKER = "__CURSOR_SHELL_EXIT__";
9
12
  const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
13
+ /** Private marker for Cursor `background_shell_spawn_args` detach wrappers. */
14
+ export const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
10
15
  const policies = new Map();
11
16
  const outcomes = new Map();
12
- function remember(map, key, value) {
17
+ /** callIDs that need shell.env injectors or a direct-command fallback. */
18
+ const pendingEnvWraps = new Set();
19
+ const activeEnvWraps = new Map();
20
+ let configuredShell;
21
+ /** Track OpenCode's configured shell from the classic config hook. */
22
+ export function setCursorShellPath(shell) {
23
+ configuredShell = shell?.trim() || undefined;
24
+ }
25
+ function executableOnPath(name) {
26
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
27
+ if (dir && existsSync(join(dir, name)))
28
+ return true;
29
+ }
30
+ return false;
31
+ }
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export function resolveCursorShellKind(shell = configuredShell ?? process.env.SHELL) {
37
+ let name = shell ? basename(shell).toLowerCase().replace(/\.exe$/, "") : "";
38
+ if (name === "fish" || name === "nu" || !name) {
39
+ name = executableOnPath("bash") ? "bash" : "sh";
40
+ }
41
+ if (name === "bash" || name === "zsh" || name === "sh" || name === "dash")
42
+ return name;
43
+ return "other";
44
+ }
45
+ function remember(map, key, value, onEvict) {
13
46
  map.delete(key);
14
47
  map.set(key, value);
15
48
  while (map.size > MAX_TRACKED_SHELL_CALLS) {
16
49
  const oldest = map.keys().next().value;
17
50
  if (!oldest)
18
51
  break;
52
+ const evicted = map.get(oldest);
19
53
  map.delete(oldest);
54
+ if (evicted !== undefined && onEvict)
55
+ onEvict(evicted);
20
56
  }
21
57
  }
22
58
  function finiteNonNegative(value) {
@@ -26,7 +62,18 @@ function finiteNonNegative(value) {
26
62
  return Math.floor(n);
27
63
  }
28
64
  export function shellPolicyFromMetadata(metadata) {
29
- if (!metadata || metadata.shell_stream !== true)
65
+ if (!metadata)
66
+ return undefined;
67
+ if (metadata.background_shell_spawn === true) {
68
+ return {
69
+ command: typeof metadata.command === "string" ? metadata.command : "",
70
+ workingDirectory: typeof metadata.working_directory === "string" ? metadata.working_directory : "",
71
+ timeoutMs: 0,
72
+ timeoutBehavior: 0,
73
+ backgroundSpawn: true,
74
+ };
75
+ }
76
+ if (metadata.shell_stream !== true)
30
77
  return undefined;
31
78
  const timeoutMs = finiteNonNegative(metadata.timeout_ms) ?? 30_000;
32
79
  const timeoutBehavior = finiteNonNegative(metadata.timeout_behavior) ?? 0;
@@ -77,29 +124,199 @@ export function buildSoftBackgroundCommand(policy) {
77
124
  else {
78
125
  lines.push('cursor_shell_status=""', 'cursor_shell_watchdog_pid=""');
79
126
  }
80
- lines.push("cursor_shell_poll=0", `while [ "$cursor_shell_poll" -lt ${polls} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", "done", 'if kill -0 "$cursor_shell_pid" 2>/dev/null; then', ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", "fi", 'wait "$cursor_shell_pid"', "cursor_shell_code=$?", 'cat "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; then', ` printf '\n${TIMEOUT_MARKER}%s\n' ${policy.hardTimeoutMs ?? policy.timeoutMs}`, "else", ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${EXIT_MARKER}%s\n' "$cursor_shell_code"`, "fi", 'rm -f -- "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ]; then rm -f -- "$cursor_shell_status"; fi');
127
+ lines.push(
128
+ // Avoid interactive job-control noise ("Terminated: 15 …") when we later
129
+ // reap the watchdog; that text can otherwise land after our private marker
130
+ // and leak into OpenCode's bash UI.
131
+ "set +m 2>/dev/null || true", 'if [ -n "$cursor_shell_watchdog_pid" ]; then disown "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', 'disown "$cursor_shell_pid" 2>/dev/null || true', "cursor_shell_poll=0", `while [ "$cursor_shell_poll" -lt ${polls} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", "done", 'if kill -0 "$cursor_shell_pid" 2>/dev/null; then', ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", "fi", 'wait "$cursor_shell_pid" 2>/dev/null', "cursor_shell_code=$?", 'cat "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; then',
132
+ // Reap the watchdog before printing the private marker so any residual
133
+ // shell diagnostics cannot trail the sentinel.
134
+ ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; wait "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${TIMEOUT_MARKER}%s\n' ${policy.hardTimeoutMs ?? policy.timeoutMs}`, "else", ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; wait "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${EXIT_MARKER}%s\n' "$cursor_shell_code"`, "fi", 'rm -f -- "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ]; then rm -f -- "$cursor_shell_status"; fi');
81
135
  return lines.join("\n");
82
136
  }
83
- /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
137
+ /**
138
+ * F11 / background_shell_spawn_args helper.
139
+ *
140
+ * OpenCode's bash tool is foreground-only. Detach the requested command inside
141
+ * that one foreground call (`nohup … &`) and print a private marker containing
142
+ * the spawned PID and log path. With stdin and all output redirected, the host
143
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
144
+ *
145
+ * Residual: the detached child is not reaped by this provider after OpenCode
146
+ * completes the tool call; cleanup is left to the user / OS.
147
+ */
148
+ export function buildBackgroundShellCommand(command) {
149
+ return [
150
+ 'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
151
+ `nohup sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
152
+ "bg_pid=$!",
153
+ `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
154
+ ].join("\n");
155
+ }
156
+ function wrapperBodyForPolicy(policy) {
157
+ if (policy.backgroundSpawn)
158
+ return buildBackgroundShellCommand(policy.command);
159
+ if (policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)
160
+ return buildSoftBackgroundCommand(policy);
161
+ return undefined;
162
+ }
163
+ function writeShellEnvInjector(wrapperBody) {
164
+ const dir = mkdtempSync(join(tmpdir(), "cursor-opencode-wrap-"));
165
+ const wrapperPath = join(dir, "wrapper.sh");
166
+ const bashEnvPath = join(dir, "bashenv.sh");
167
+ const zshenvPath = join(dir, ".zshenv");
168
+ writeFileSync(wrapperPath, `${wrapperBody}\n`, { mode: 0o700 });
169
+ // Sourced by bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR). `exec`
170
+ // replaces the host shell before OpenCode's `-c <original>` body runs.
171
+ const injector = [
172
+ "unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
173
+ `exec /bin/sh ${shellQuote(wrapperPath)}`,
174
+ "",
175
+ ].join("\n");
176
+ writeFileSync(bashEnvPath, injector, { mode: 0o600 });
177
+ writeFileSync(zshenvPath, injector, { mode: 0o600 });
178
+ return {
179
+ wrapperPath,
180
+ env: {
181
+ BASH_ENV: bashEnvPath,
182
+ ZDOTDIR: dir,
183
+ },
184
+ cleanup: () => {
185
+ try {
186
+ rmSync(dir, { recursive: true, force: true });
187
+ }
188
+ catch {
189
+ // best-effort temp cleanup
190
+ }
191
+ },
192
+ };
193
+ }
194
+ function ensureShellEnvWrap(toolCallId, policy) {
195
+ const existing = activeEnvWraps.get(toolCallId);
196
+ if (existing)
197
+ return existing;
198
+ const wrapperBody = wrapperBodyForPolicy(policy);
199
+ if (!wrapperBody)
200
+ return undefined;
201
+ const wrap = writeShellEnvInjector(wrapperBody);
202
+ remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
203
+ return wrap;
204
+ }
205
+ /**
206
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
207
+ *
208
+ * bash/zsh source the shell.env injector, so the original command remains in
209
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
210
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
211
+ *
212
+ * background_shell_spawn may already contain the inline non-plugin fallback.
213
+ * The classic hook replaces it with the original command (bash/zsh) or the
214
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
215
+ */
84
216
  export function prepareCursorShellArgs(toolCallId, args) {
85
217
  const policy = policies.get(toolCallId);
86
- if (!policy || policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
218
+ if (!policy)
87
219
  return;
88
- args.command = buildSoftBackgroundCommand(policy);
89
- // The wrapper returns just after Cursor's foreground window. OpenCode's own
90
- // timeout is only an outer safety net and must not win the race.
91
- args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
220
+ if (!policy.backgroundSpawn && policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
221
+ return;
222
+ pendingEnvWraps.add(toolCallId);
223
+ if (!policy.backgroundSpawn) {
224
+ // The wrapper returns just after Cursor's foreground window. OpenCode's own
225
+ // timeout is only an outer safety net and must not win the race.
226
+ args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
227
+ }
228
+ const shellKind = resolveCursorShellKind();
229
+ if (process.platform === "win32" || shellKind === "bash" || shellKind === "zsh") {
230
+ // Native Windows PowerShell/cmd wrapping remains unsupported; do not emit
231
+ // a POSIX /bin/sh command there. Git Bash still uses the env path above.
232
+ args.command = policy.command;
233
+ return;
234
+ }
235
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
236
+ if (wrap)
237
+ args.command = `exec /bin/sh ${shellQuote(wrap.wrapperPath)}`;
92
238
  }
93
239
  /** Restore the model-facing command in OpenCode's completed tool title. */
94
240
  export function cursorShellOriginalCommand(toolCallId) {
95
241
  return policies.get(toolCallId)?.command || undefined;
96
242
  }
243
+ /** Drop injector temp files for a finished/abandoned Cursor shell call. */
244
+ export function releaseCursorShellEnv(toolCallId) {
245
+ pendingEnvWraps.delete(toolCallId);
246
+ const active = activeEnvWraps.get(toolCallId);
247
+ if (!active)
248
+ return;
249
+ activeEnvWraps.delete(toolCallId);
250
+ active.cleanup();
251
+ }
252
+ /**
253
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
254
+ * same materialized wrapper backs the direct-command sh/dash fallback.
255
+ */
256
+ export function cursorShellEnvForCall(toolCallId) {
257
+ if (typeof toolCallId !== "string" || !toolCallId || !pendingEnvWraps.has(toolCallId))
258
+ return undefined;
259
+ const policy = policies.get(toolCallId);
260
+ if (!policy)
261
+ return undefined;
262
+ const wrap = ensureShellEnvWrap(toolCallId, policy);
263
+ if (!wrap)
264
+ return undefined;
265
+ pendingEnvWraps.delete(toolCallId);
266
+ return wrap.env;
267
+ }
97
268
  function withoutMarker(output, index) {
98
269
  let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
99
270
  if (clean.trim() === "" || clean.trim() === "(no output)")
100
271
  clean = "";
101
272
  return clean;
102
273
  }
274
+ /**
275
+ * OpenCode only stores/renders text (`output` / `metadata.output`). Private
276
+ * markers become typed Cursor outcomes, but stripping them alone can leave a
277
+ * blank or partial bash bubble that looks like success. Append a short
278
+ * user-facing status so the UI explains background handoff / timeout.
279
+ */
280
+ function formatShellOutcomeDisplay(clean, outcome) {
281
+ let notice;
282
+ if (outcome.kind === "backgrounded") {
283
+ notice = outcome.msToWait > 0
284
+ ? `Still running in the background (pid ${outcome.pid}) after ${outcome.msToWait}ms.`
285
+ : `Started in the background (pid ${outcome.pid}).`;
286
+ }
287
+ else if (outcome.kind === "timeout") {
288
+ notice = `Timed out after ${outcome.timeoutMs}ms.`;
289
+ }
290
+ if (!notice)
291
+ return clean;
292
+ if (!clean)
293
+ return `${notice}\n`;
294
+ return clean.endsWith("\n") ? `${clean}${notice}\n` : `${clean}\n${notice}\n`;
295
+ }
296
+ /**
297
+ * Find the last private wrapper sentinel.
298
+ *
299
+ * Soft-background wrappers print the marker as the final intentional line, but
300
+ * the host shell can still append job-control diagnostics afterwards (e.g.
301
+ * "Terminated: 15 … nohup sh -c '…cursor-shell-watchdog…'"). Match the sentinel
302
+ * on its own line and discard everything from that point to EOF.
303
+ */
304
+ function lastPrivateMarker(output, marker, valuePattern) {
305
+ const re = new RegExp(`(?:^|\\r?\\n)(${marker}${valuePattern})`, "g");
306
+ let match;
307
+ let last;
308
+ while ((match = re.exec(output)) !== null) {
309
+ if (match.index === undefined || match[1] === undefined)
310
+ continue;
311
+ const index = match[0].startsWith("\r\n")
312
+ ? match.index + 2
313
+ : match[0].startsWith("\n")
314
+ ? match.index + 1
315
+ : match.index;
316
+ last = { index, values: match.slice(2) };
317
+ }
318
+ return last;
319
+ }
103
320
  function parseOpenCodeTimeout(output) {
104
321
  const re = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\.[\s\S]*?<\/shell_metadata>\s*$/;
105
322
  const match = re.exec(output);
@@ -107,10 +324,10 @@ function parseOpenCodeTimeout(output) {
107
324
  return undefined;
108
325
  return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
109
326
  }
110
- function parseWrapperOutcome(output, policy) {
111
- const background = new RegExp(`${BACKGROUND_MARKER}(\\d+):([^\\r\\n]+)\\s*$`).exec(output);
112
- if (background?.index !== undefined) {
113
- const pid = Number(background[1]);
327
+ function parseSoftBackgroundOutcome(output, policy) {
328
+ const background = lastPrivateMarker(output, BACKGROUND_MARKER, "(\\d+):([^\\r\\n]+)");
329
+ if (background) {
330
+ const pid = Number(background.values[0]);
114
331
  if (Number.isSafeInteger(pid) && pid > 0 && pid <= 0xffff_ffff) {
115
332
  return {
116
333
  output: withoutMarker(output, background.index),
@@ -126,43 +343,101 @@ function parseWrapperOutcome(output, policy) {
126
343
  };
127
344
  }
128
345
  }
129
- const timeout = new RegExp(`${TIMEOUT_MARKER}(\\d+)\\s*$`).exec(output);
130
- if (timeout?.index !== undefined) {
346
+ const timeout = lastPrivateMarker(output, TIMEOUT_MARKER, "(\\d+)");
347
+ if (timeout) {
131
348
  return {
132
349
  output: withoutMarker(output, timeout.index),
133
- outcome: { kind: "timeout", timeoutMs: Number(timeout[1]) },
350
+ outcome: { kind: "timeout", timeoutMs: Number(timeout.values[0]) },
134
351
  };
135
352
  }
136
- const exit = new RegExp(`${EXIT_MARKER}(-?\\d+)\\s*$`).exec(output);
137
- if (exit?.index !== undefined) {
353
+ const exit = lastPrivateMarker(output, EXIT_MARKER, "(-?\\d+)");
354
+ if (exit) {
138
355
  return {
139
356
  output: withoutMarker(output, exit.index),
140
- outcome: { kind: "exit", code: Number(exit[1]) },
357
+ outcome: { kind: "exit", code: Number(exit.values[0]) },
141
358
  };
142
359
  }
143
360
  return undefined;
144
361
  }
362
+ function parseBackgroundSpawnOutcome(output, policy) {
363
+ const match = lastPrivateMarker(output, BACKGROUND_SHELL_MARKER, "(\\d+):([^\\r\\n]+)");
364
+ if (!match)
365
+ return undefined;
366
+ const pid = Number(match.values[0]);
367
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff)
368
+ return undefined;
369
+ return {
370
+ output: withoutMarker(output, match.index),
371
+ outcome: {
372
+ kind: "backgrounded",
373
+ shellId: pid,
374
+ pid,
375
+ command: policy?.command ?? "",
376
+ workingDirectory: policy?.workingDirectory ?? "",
377
+ msToWait: 0,
378
+ reason: 1,
379
+ },
380
+ };
381
+ }
382
+ /**
383
+ * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
384
+ * Does not record outcomes — use {@link captureCursorShellResult} for that.
385
+ */
386
+ export function sanitizeCursorShellDisplayOutput(output, policy) {
387
+ if (policy?.backgroundSpawn) {
388
+ const spawn = parseBackgroundSpawnOutcome(output, policy);
389
+ if (spawn)
390
+ return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
391
+ }
392
+ if (policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND) {
393
+ const wrapper = parseSoftBackgroundOutcome(output, policy);
394
+ if (wrapper)
395
+ return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
396
+ }
397
+ const timeout = parseOpenCodeTimeout(output);
398
+ if (timeout) {
399
+ return formatShellOutcomeDisplay(timeout.output, {
400
+ kind: "timeout",
401
+ timeoutMs: timeout.timeoutMs,
402
+ });
403
+ }
404
+ return output;
405
+ }
406
+ /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
407
+ export function sanitizeRegisteredCursorShellOutput(toolCallId, output) {
408
+ if (typeof toolCallId !== "string" || !toolCallId)
409
+ return output;
410
+ return sanitizeCursorShellDisplayOutput(output, policies.get(toolCallId));
411
+ }
145
412
  /**
146
413
  * Capture Bash completion in the classic plugin's after hook. Returns the
147
414
  * sanitized output that OpenCode should store and render.
148
415
  */
149
416
  export function captureCursorShellResult(toolCallId, output, metadata) {
150
- if (!toolCallId.startsWith("cursor_"))
417
+ if (typeof toolCallId !== "string" || !toolCallId.startsWith("cursor_"))
151
418
  return output;
152
419
  const policy = policies.get(toolCallId);
420
+ if (policy?.backgroundSpawn) {
421
+ const spawn = parseBackgroundSpawnOutcome(output, policy);
422
+ if (spawn) {
423
+ remember(outcomes, toolCallId, spawn.outcome);
424
+ return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
425
+ }
426
+ }
153
427
  // Private wrapper sentinels are meaningful only for calls we transformed.
154
428
  // A normal foreground command is allowed to print the same text verbatim.
155
429
  const wrapper = policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
156
- ? parseWrapperOutcome(output, policy)
430
+ ? parseSoftBackgroundOutcome(output, policy)
157
431
  : undefined;
158
432
  if (wrapper) {
159
433
  remember(outcomes, toolCallId, wrapper.outcome);
160
- return wrapper.output;
434
+ return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
161
435
  }
162
436
  const timeout = parseOpenCodeTimeout(output);
163
437
  if (timeout) {
164
- remember(outcomes, toolCallId, { kind: "timeout", timeoutMs: timeout.timeoutMs });
165
- return timeout.output;
438
+ const outcome = { kind: "timeout", timeoutMs: timeout.timeoutMs };
439
+ remember(outcomes, toolCallId, outcome);
440
+ return formatShellOutcomeDisplay(timeout.output, outcome);
166
441
  }
167
442
  const exitCode = finiteNonNegative(metadata?.exit);
168
443
  if (exitCode !== undefined)
@@ -171,16 +446,25 @@ export function captureCursorShellResult(toolCallId, output, metadata) {
171
446
  }
172
447
  /** Consume the structured result, with an inline fallback when no plugin hook ran. */
173
448
  export function consumeCursorShellResult(toolCallId, output) {
449
+ if (typeof toolCallId !== "string" || !toolCallId) {
450
+ return { output };
451
+ }
174
452
  let clean = output;
175
453
  if (!outcomes.has(toolCallId))
176
454
  clean = captureCursorShellResult(toolCallId, output);
177
455
  const outcome = outcomes.get(toolCallId);
178
456
  outcomes.delete(toolCallId);
179
457
  policies.delete(toolCallId);
458
+ releaseCursorShellEnv(toolCallId);
180
459
  return { output: clean, outcome };
181
460
  }
182
461
  /** Test/process cleanup. */
183
462
  export function resetCursorShellCalls() {
463
+ for (const wrap of activeEnvWraps.values())
464
+ wrap.cleanup();
465
+ activeEnvWraps.clear();
466
+ pendingEnvWraps.clear();
184
467
  policies.clear();
185
468
  outcomes.clear();
469
+ configuredShell = undefined;
186
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-opencode-provider",
3
- "version": "0.2.5",
3
+ "version": "0.2.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",
@@ -61,7 +61,13 @@
61
61
  "protobufjs": "^7.4.0"
62
62
  },
63
63
  "peerDependencies": {
64
- "@opencode-ai/plugin": "^1.17.13"
64
+ "@opencode-ai/plugin": "^1.17.13",
65
+ "@opencode-compat/profile": ">=0.1.0"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "@opencode-compat/profile": {
69
+ "optional": true
70
+ }
65
71
  },
66
72
  "devDependencies": {
67
73
  "@opencode-ai/plugin": "^1.17.13",
@@ -69,4 +75,4 @@
69
75
  "@types/node": "^22.15.3",
70
76
  "typescript": "^5.8.3"
71
77
  }
72
- }
78
+ }