cursor-opencode-provider 0.2.5 → 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.
package/README.md CHANGED
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
47
47
  }
48
48
  ```
49
49
 
50
- Pin a version if you want: `"cursor-opencode-provider@0.2.5"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.6"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -165,7 +165,7 @@ const model = cursor.languageModel("composer-2.5")
165
165
  // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
166
166
  ```
167
167
 
168
- Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. Retries occur only before visible output or stateful server activity; unsafe replay is surfaced instead of risking duplicate text or tool work. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
168
+ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
169
169
 
170
170
  ## Environment variables
171
171
 
@@ -242,7 +242,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
242
242
  | Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
243
243
  | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
244
244
  | Empty or stale model list | Delete `~/.cache/opencode/cursor-models.json` (or under `$XDG_CACHE_HOME/opencode/`) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
245
- | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, and transparently rebases once from full OpenCode history if the Run ends before `turn_ended`. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. With debug logging enabled, look for `Run interrupted` and `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
245
+ | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. With debug logging enabled, look for `Run interrupted`, `resuming checkpoint`, or `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
246
246
  | No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. |
247
247
  | Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
248
248
  | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
@@ -259,11 +259,11 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
259
259
  - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
260
260
  - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name; known-but-unsupported and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
261
261
  - **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
262
- - **Background shells are non-interactive** — Cursor's native background-shell spawn is bridged through OpenCode's foreground-only `bash` tool by detaching the requested command, redirecting its output to `${TMPDIR:-/tmp}/cursor-opencode-bg.*`, and returning the real PID in Cursor's typed field-16 result. Shell-stream requests also preserve Cursor's foreground timeout, `timeout_behavior`, and `hard_timeout`: cancellation becomes a typed aborted exit, while a soft timeout requested with background behavior becomes a typed background handoff. OpenCode's internal `<shell_metadata>` envelope and the bridge's private markers are stripped before the result reaches the UI. Requests that require `write_shell_stdin` are rejected explicitly because OpenCode does not expose an interactive background-process lifecycle through its AI SDK tool interface.
262
+ - **Background shells are non-interactive** — Cursor's native background-shell spawn and soft-background shell-stream timeouts are bridged through OpenCode's foreground-only `bash` tool. The OpenCode UI keeps the original command; wrapping happens via the classic plugin's `shell.env` injectors (`BASH_ENV` / `ZDOTDIR`) so permissions still see the real user command. Spawn/soft-bg wrappers detach with `nohup`, redirect output under `${TMPDIR:-/tmp}/cursor-opencode-{bg,shell}.*`, and return the real PID (or typed timeout/exit) to Cursor. Private markers and OpenCode's `<shell_metadata>` envelope are stripped before storage/render, with a short still-running / started / timed-out status line left for the bash bubble. Requests that require `write_shell_stdin` are rejected explicitly because OpenCode does not expose an interactive background-process lifecycle through its AI SDK tool interface. The POSIX wrap path is not implemented for native Windows PowerShell/`cmd`.
263
263
  - **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and permissions. Separately from display `create_plan_tool_call` → `todowrite` mirroring, interaction `create_plan_request_query` is auto-acked (CLI headless parity) with success and an empty `plan_uri`, so Cursor may treat the plan as accepted without an OpenCode UI confirm. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
264
264
  - **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state.
265
265
  - **Conversation bindings and compaction catalogs are bounded** — process-global per-session bindings, prior tool catalogs, and pending post-compaction rebases use a 256-session LRU bound. Evicting a conversation binding also drops its checkpoint and KV blobs.
266
- - **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the OpenCode prompt, retaining the live user request and trailing tool results as host observations without replaying stale historical tool output, re-advertises the same tools, and preserves compaction's no-execution behavior. A second interruption is returned as an explicit error. Recovery cannot see tokens already streamed to the UI on the interrupted attempt, so mid-generation recovery may briefly duplicate visible text/reasoning even though the rebase prompt asks the model not to repeat completed work.
266
+ - **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
267
267
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
268
268
 
269
269
  ## License
@@ -20,10 +20,17 @@ export declare function pumpWithRecovery(input: {
20
20
  abortSignal?: AbortSignal;
21
21
  promptTokens?: number;
22
22
  retryPolicy?: CursorRetryPolicy;
23
- recover: () => Promise<CursorSession>;
23
+ recover: (recovery: CursorRunRecovery) => Promise<CursorSession>;
24
24
  onSession?: (session: CursorSession) => void;
25
25
  maxRecoveries?: number;
26
26
  }): Promise<CursorSession>;
27
+ export type CursorRunRecovery = {
28
+ kind: "rebase";
29
+ } | {
30
+ kind: "resume";
31
+ conversationId: string;
32
+ checkpoint: Uint8Array;
33
+ };
27
34
  /**
28
35
  * OpenCode re-sends the full tool-result history on every continuation. Prefer
29
36
  * the newest result that still has a live pending exec on its tagged session.
@@ -50,6 +57,9 @@ export declare function pump(session: CursorSession, controller: ReadableStreamD
50
57
  textId: string;
51
58
  reasoningId: string;
52
59
  promptTokens?: number;
60
+ requestUsage?: {
61
+ outputChars: number;
62
+ };
53
63
  }, abortSignal?: AbortSignal): Promise<void>;
54
64
  type ExtractedToolResult = {
55
65
  toolCallId: string;
@@ -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());
@@ -440,8 +452,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
440
452
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
441
453
  : baseSystemPrompt;
442
454
  const history = extractPromptHistory(prompt, {
443
- preserveTrailingUser: recovery,
444
- toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
455
+ preserveTrailingUser: recovery?.kind === "rebase",
456
+ toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
445
457
  });
446
458
  await loadAvailableModels();
447
459
  // Resolve the region-specific Run stream origin once per process (memoized
@@ -485,7 +497,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
485
497
  : [];
486
498
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
487
499
  // After compaction reset there is no checkpoint — seed from OpenCode history.
488
- const conversationState = bound.reset ? undefined : getCheckpoint(conversationId);
500
+ const conversationState = resuming
501
+ ? recovery.checkpoint
502
+ : (bound.reset ? undefined : getCheckpoint(conversationId));
489
503
  const reqBytes = buildRunRequest({
490
504
  text: userText,
491
505
  modelId: cursorModelId,
@@ -498,6 +512,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
498
512
  tools,
499
513
  toolDescriptors,
500
514
  requestContext,
515
+ action: resuming ? "resume" : "user",
501
516
  });
502
517
  // Content hashes — Cursor content-addresses large payloads; logging these lets
503
518
  // us match a server get_blob_args.blob_id to what it wants served.
@@ -523,6 +538,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
523
538
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
524
539
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
525
540
  `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
541
+ `resume=${resuming} ` +
526
542
  `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
527
543
  if (hooksCtx)
528
544
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
@@ -541,6 +557,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
541
557
  const session = {
542
558
  sessionId: crypto.randomUUID(),
543
559
  conversationId,
560
+ resumeCheckpoint: undefined,
544
561
  openCodeSessionId: sessionKey,
545
562
  stream,
546
563
  frames: stream.frames()[Symbol.asyncIterator](),
@@ -646,6 +663,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
646
663
  if (!pending.bridged) {
647
664
  try {
648
665
  const shellResult = pending.resultField === "shell_stream"
666
+ || pending.resultField === "background_shell_spawn_result"
649
667
  ? consumeCursorShellResult(r.toolCallId, r.output)
650
668
  : undefined;
651
669
  frames = buildExecClientMessages({
@@ -795,7 +813,7 @@ export async function pump(session, controller, ids, abortSignal) {
795
813
  const advertisedToolNameSet = new Set(advertisedToolNames);
796
814
  let textStarted = false;
797
815
  let reasoningStarted = false;
798
- let emittedOutputChars = 0;
816
+ const requestUsage = ids.requestUsage ?? { outputChars: 0 };
799
817
  let replaySafe = true;
800
818
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
801
819
  // in doStreamImpl). The frames iterator can still yield a final `done` after
@@ -923,7 +941,7 @@ export async function pump(session, controller, ids, abortSignal) {
923
941
  }
924
942
  session.usageEstimate.outputTokens += estimateTokens(text.length);
925
943
  if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
926
- emittedOutputChars += text.length;
944
+ requestUsage.outputChars += text.length;
927
945
  }
928
946
  };
929
947
  const emitReasoning = (text) => {
@@ -936,7 +954,7 @@ export async function pump(session, controller, ids, abortSignal) {
936
954
  }
937
955
  session.usageEstimate.outputTokens += estimateTokens(text.length);
938
956
  if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
939
- emittedOutputChars += text.length;
957
+ requestUsage.outputChars += text.length;
940
958
  }
941
959
  };
942
960
  const emitFinish = (te, reason) => {
@@ -959,7 +977,7 @@ export async function pump(session, controller, ids, abortSignal) {
959
977
  cacheWrite: 0,
960
978
  },
961
979
  outputTokens: {
962
- total: estimateTokens(emittedOutputChars),
980
+ total: estimateTokens(requestUsage.outputChars),
963
981
  text: undefined,
964
982
  reasoning: undefined,
965
983
  },
@@ -1097,6 +1115,7 @@ export async function pump(session, controller, ids, abortSignal) {
1097
1115
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1098
1116
  if (bytes && bytes.length > 0) {
1099
1117
  setCheckpoint(session.conversationId, bytes);
1118
+ session.resumeCheckpoint = Uint8Array.from(bytes);
1100
1119
  trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
1101
1120
  }
1102
1121
  }
@@ -1279,7 +1298,8 @@ export async function pump(session, controller, ids, abortSignal) {
1279
1298
  trace(`exec: claimed display callId=${displayCallId}`);
1280
1299
  }
1281
1300
  const tc = buildToolCallPart(parsed, session.sessionId);
1282
- if (parsed.resultField === "shell_stream") {
1301
+ if (parsed.resultField === "shell_stream"
1302
+ || parsed.resultField === "background_shell_spawn_result") {
1283
1303
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
1284
1304
  }
1285
1305
  // Keep the stream open; the result arrives on the next doStream call.
package/dist/plugin.js CHANGED
@@ -4,7 +4,7 @@ import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, dis
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
  /**
@@ -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)
@@ -903,7 +903,9 @@ export function createMessageTypes() {
903
903
  { id: 2, name: "request_context", type: "RequestContext" },
904
904
  ]);
905
905
  addType(root, "ResumeAction", [
906
- { 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" },
907
909
  ]);
908
910
  addType(root, "CancelAction", [
909
911
  { id: 1, name: "conversation_id", type: "string" },
@@ -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
@@ -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;
@@ -3,6 +3,7 @@ import { encodeMessage } from "./messages.js";
3
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
4
4
  import { trace } from "../debug.js";
5
5
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
6
+ import { BACKGROUND_SHELL_MARKER } from "../shell-timeout.js";
6
7
  // Exec variant field number whose reply is the server-initiated request_context
7
8
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
8
9
  // field number for every exec variant, so this is also the result field.
@@ -137,7 +138,7 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
137
138
  // Design note (F11): Cursor has native delete / background-shell tools; OpenCode
138
139
  // does not. This provider intentionally remaps:
139
140
  // - delete_args → bash `rm -f -- <quoted-path>`
140
- // - background_shell_spawn_args → bash + nohup detach wrapper
141
+ // - background_shell_spawn_args → bash (original command; plugin wraps nohup)
141
142
  // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
142
143
  // background / detached children can outlive the OpenCode tool call; leftover
143
144
  // process cleanup is left to the user / OS. Arg key remapping happens below.
@@ -213,7 +214,6 @@ export function mapExecServerToToolName(execField) {
213
214
  export function mapToolNameToExecField(toolName) {
214
215
  return opencodeToolToCursor[toolName];
215
216
  }
216
- const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
217
217
  export function parseExecServerMessage(msg) {
218
218
  const id = msg.id;
219
219
  if (id === undefined)
@@ -235,8 +235,8 @@ export function parseExecServerMessage(msg) {
235
235
  if (!resultField)
236
236
  return undefined;
237
237
  const execId = msg.exec_id ?? "";
238
- // F11: Cursor native background shell → OpenCode bash. The wrapper detaches
239
- // via nohup immediately; OpenCode only sees the spawn marker (pid + log path).
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
240
  // Residual: the child can keep running after this tool call completes.
241
241
  if (execVariant === "background_shell_spawn_args") {
242
242
  const raw = msg.background_shell_spawn_args ?? {};
@@ -244,7 +244,7 @@ export function parseExecServerMessage(msg) {
244
244
  const workingDirectory = str(raw.working_directory) ?? "";
245
245
  const args = {};
246
246
  if (command)
247
- args.command = buildBackgroundShellCommand(command);
247
+ args.command = command;
248
248
  if (workingDirectory)
249
249
  args.workdir = workingDirectory;
250
250
  return {
@@ -253,7 +253,11 @@ export function parseExecServerMessage(msg) {
253
253
  toolName: "bash",
254
254
  args,
255
255
  resultField,
256
- resultMetadata: { command: command ?? "", working_directory: workingDirectory },
256
+ resultMetadata: {
257
+ background_shell_spawn: true,
258
+ command: command ?? "",
259
+ working_directory: workingDirectory,
260
+ },
257
261
  localError: raw.enable_write_shell_stdin_tool === true
258
262
  ? "Interactive background shells are not available through OpenCode's bash tool."
259
263
  : command
@@ -540,25 +544,6 @@ function mapCursorSubagentTypeToOpenCode(subagentType) {
540
544
  function shellQuote(s) {
541
545
  return `'${s.replace(/'/g, `'\\''`)}'`;
542
546
  }
543
- /**
544
- * F11 / background_shell_spawn_args helper.
545
- *
546
- * OpenCode's bash tool is foreground-only. Detach the requested command inside
547
- * that one foreground call (`nohup … &`) and print a private marker containing
548
- * the spawned PID and log path. With stdin and all output redirected, the host
549
- * shell can return immediately instead of retaining OpenCode's tool pipe.
550
- *
551
- * Residual: the detached child is not reaped by this provider after OpenCode
552
- * completes the tool call; cleanup is left to the user / OS.
553
- */
554
- function buildBackgroundShellCommand(command) {
555
- return [
556
- 'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
557
- `nohup sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
558
- "bg_pid=$!",
559
- `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
560
- ].join("\n");
561
- }
562
547
  /**
563
548
  * Map Cursor McpArgs back to the OpenCode tool id.
564
549
  * Prefers provider_identifier + bare tool_name (github + create_pull_request
@@ -669,7 +654,7 @@ export function buildExecClientMessages(input) {
669
654
  id: input.execId,
670
655
  local_execution_time_ms: input.executionTimeMs ?? 0,
671
656
  };
672
- 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);
673
658
  frames.push(encodeMessage("AgentClientMessage", {
674
659
  exec_client_message: clientMsg,
675
660
  }));
@@ -756,7 +741,7 @@ export function unwrapReadOutput(output) {
756
741
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
757
742
  * server accepts (verified against agent.v1 wire captures).
758
743
  */
759
- export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
744
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome) {
760
745
  switch (resultField) {
761
746
  case "read_result": {
762
747
  const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
@@ -841,6 +826,19 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
841
826
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
842
827
  if (error)
843
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.
844
842
  const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
845
843
  const pid = match ? Number(match[1]) : 0;
846
844
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
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";
@@ -39,10 +43,48 @@ export declare function registerCursorShellCall(toolCallId: string, metadata: Re
39
43
  * reap leftover processes — cleanup is left to the user / OS.
40
44
  */
41
45
  export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
42
- /** 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
+ */
43
71
  export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
44
72
  /** Restore the model-facing command in OpenCode's completed tool title. */
45
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;
46
88
  /**
47
89
  * Capture Bash completion in the classic plugin's after hook. Returns the
48
90
  * sanitized output that OpenCode should store and render.
@@ -1,3 +1,6 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { 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,24 @@ 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 (so args.command can stay display-original). */
18
+ const pendingEnvWraps = new Set();
19
+ const activeEnvWraps = new Map();
20
+ function remember(map, key, value, onEvict) {
13
21
  map.delete(key);
14
22
  map.set(key, value);
15
23
  while (map.size > MAX_TRACKED_SHELL_CALLS) {
16
24
  const oldest = map.keys().next().value;
17
25
  if (!oldest)
18
26
  break;
27
+ const evicted = map.get(oldest);
19
28
  map.delete(oldest);
29
+ if (evicted !== undefined && onEvict)
30
+ onEvict(evicted);
20
31
  }
21
32
  }
22
33
  function finiteNonNegative(value) {
@@ -26,7 +37,18 @@ function finiteNonNegative(value) {
26
37
  return Math.floor(n);
27
38
  }
28
39
  export function shellPolicyFromMetadata(metadata) {
29
- if (!metadata || metadata.shell_stream !== true)
40
+ if (!metadata)
41
+ return undefined;
42
+ if (metadata.background_shell_spawn === true) {
43
+ return {
44
+ command: typeof metadata.command === "string" ? metadata.command : "",
45
+ workingDirectory: typeof metadata.working_directory === "string" ? metadata.working_directory : "",
46
+ timeoutMs: 0,
47
+ timeoutBehavior: 0,
48
+ backgroundSpawn: true,
49
+ };
50
+ }
51
+ if (metadata.shell_stream !== true)
30
52
  return undefined;
31
53
  const timeoutMs = finiteNonNegative(metadata.timeout_ms) ?? 30_000;
32
54
  const timeoutBehavior = finiteNonNegative(metadata.timeout_behavior) ?? 0;
@@ -77,15 +99,59 @@ export function buildSoftBackgroundCommand(policy) {
77
99
  else {
78
100
  lines.push('cursor_shell_status=""', 'cursor_shell_watchdog_pid=""');
79
101
  }
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');
102
+ lines.push(
103
+ // Avoid interactive job-control noise ("Terminated: 15 …") when we later
104
+ // reap the watchdog; that text can otherwise land after our private marker
105
+ // and leak into OpenCode's bash UI.
106
+ "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',
107
+ // Reap the watchdog before printing the private marker so any residual
108
+ // shell diagnostics cannot trail the sentinel.
109
+ ' 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
110
  return lines.join("\n");
82
111
  }
83
- /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
112
+ /**
113
+ * F11 / background_shell_spawn_args helper.
114
+ *
115
+ * OpenCode's bash tool is foreground-only. Detach the requested command inside
116
+ * that one foreground call (`nohup … &`) and print a private marker containing
117
+ * the spawned PID and log path. With stdin and all output redirected, the host
118
+ * shell can return immediately instead of retaining OpenCode's tool pipe.
119
+ *
120
+ * Residual: the detached child is not reaped by this provider after OpenCode
121
+ * completes the tool call; cleanup is left to the user / OS.
122
+ */
123
+ export function buildBackgroundShellCommand(command) {
124
+ return [
125
+ 'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
126
+ `nohup sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
127
+ "bg_pid=$!",
128
+ `printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
129
+ ].join("\n");
130
+ }
131
+ /**
132
+ * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
133
+ *
134
+ * Important: do **not** replace `args.command` with the wrapper script.
135
+ * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
136
+ * persists the execute-time `args` object into that field. Mutating
137
+ * `args.command` therefore leaks the private wrapper into the TUI/GUI.
138
+ *
139
+ * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
140
+ * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
141
+ * replaced with the wrapper while the stored/displayed command stays original.
142
+ * Permissions also keep analyzing the real user command.
143
+ */
84
144
  export function prepareCursorShellArgs(toolCallId, args) {
85
145
  const policy = policies.get(toolCallId);
86
- if (!policy || policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
146
+ if (!policy)
147
+ return;
148
+ if (policy.backgroundSpawn) {
149
+ pendingEnvWraps.add(toolCallId);
87
150
  return;
88
- args.command = buildSoftBackgroundCommand(policy);
151
+ }
152
+ if (policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
153
+ return;
154
+ pendingEnvWraps.add(toolCallId);
89
155
  // The wrapper returns just after Cursor's foreground window. OpenCode's own
90
156
  // timeout is only an outer safety net and must not win the race.
91
157
  args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
@@ -94,12 +160,127 @@ export function prepareCursorShellArgs(toolCallId, args) {
94
160
  export function cursorShellOriginalCommand(toolCallId) {
95
161
  return policies.get(toolCallId)?.command || undefined;
96
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 non-interactive bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR).
170
+ // `exec` replaces the host shell so OpenCode's `-c <original>` body never runs.
171
+ //
172
+ // Do not gate on a sticky env flag: soft-background children inherit the
173
+ // wrapper environment, and a leftover CURSOR_OPENCODE_WRAP_ACTIVE=1 would
174
+ // make later injectors no-op (original `-c` runs unwrapped). Unsetting the
175
+ // injector vars before `exec` is enough to prevent re-entry.
176
+ const injector = [
177
+ "unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
178
+ `exec /bin/sh ${shellQuote(wrapperPath)}`,
179
+ "",
180
+ ].join("\n");
181
+ writeFileSync(bashEnvPath, injector, { mode: 0o600 });
182
+ writeFileSync(zshenvPath, injector, { mode: 0o600 });
183
+ return {
184
+ env: {
185
+ BASH_ENV: bashEnvPath,
186
+ ZDOTDIR: dir,
187
+ },
188
+ cleanup: () => {
189
+ try {
190
+ rmSync(dir, { recursive: true, force: true });
191
+ }
192
+ catch {
193
+ // best-effort temp cleanup
194
+ }
195
+ },
196
+ };
197
+ }
198
+ /** Drop injector temp files for a finished/abandoned Cursor shell call. */
199
+ export function releaseCursorShellEnv(toolCallId) {
200
+ pendingEnvWraps.delete(toolCallId);
201
+ const active = activeEnvWraps.get(toolCallId);
202
+ if (!active)
203
+ return;
204
+ activeEnvWraps.delete(toolCallId);
205
+ active.cleanup();
206
+ }
207
+ /**
208
+ * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
209
+ * wrapper while `args.command` (and therefore the bash UI) stay original.
210
+ */
211
+ export function cursorShellEnvForCall(toolCallId) {
212
+ if (typeof toolCallId !== "string" || !toolCallId || !pendingEnvWraps.has(toolCallId))
213
+ return undefined;
214
+ const policy = policies.get(toolCallId);
215
+ if (!policy)
216
+ return undefined;
217
+ const existing = activeEnvWraps.get(toolCallId);
218
+ if (existing)
219
+ return existing.env;
220
+ const wrapperBody = policy.backgroundSpawn
221
+ ? buildBackgroundShellCommand(policy.command)
222
+ : policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
223
+ ? buildSoftBackgroundCommand(policy)
224
+ : undefined;
225
+ if (!wrapperBody)
226
+ return undefined;
227
+ const wrap = writeShellEnvInjector(wrapperBody);
228
+ remember(activeEnvWraps, toolCallId, wrap, (evicted) => evicted.cleanup());
229
+ pendingEnvWraps.delete(toolCallId);
230
+ return wrap.env;
231
+ }
97
232
  function withoutMarker(output, index) {
98
233
  let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
99
234
  if (clean.trim() === "" || clean.trim() === "(no output)")
100
235
  clean = "";
101
236
  return clean;
102
237
  }
238
+ /**
239
+ * OpenCode only stores/renders text (`output` / `metadata.output`). Private
240
+ * markers become typed Cursor outcomes, but stripping them alone can leave a
241
+ * blank or partial bash bubble that looks like success. Append a short
242
+ * user-facing status so the UI explains background handoff / timeout.
243
+ */
244
+ function formatShellOutcomeDisplay(clean, outcome) {
245
+ let notice;
246
+ if (outcome.kind === "backgrounded") {
247
+ notice = outcome.msToWait > 0
248
+ ? `Still running in the background (pid ${outcome.pid}) after ${outcome.msToWait}ms.`
249
+ : `Started in the background (pid ${outcome.pid}).`;
250
+ }
251
+ else if (outcome.kind === "timeout") {
252
+ notice = `Timed out after ${outcome.timeoutMs}ms.`;
253
+ }
254
+ if (!notice)
255
+ return clean;
256
+ if (!clean)
257
+ return `${notice}\n`;
258
+ return clean.endsWith("\n") ? `${clean}${notice}\n` : `${clean}\n${notice}\n`;
259
+ }
260
+ /**
261
+ * Find the last private wrapper sentinel.
262
+ *
263
+ * Soft-background wrappers print the marker as the final intentional line, but
264
+ * the host shell can still append job-control diagnostics afterwards (e.g.
265
+ * "Terminated: 15 … nohup sh -c '…cursor-shell-watchdog…'"). Match the sentinel
266
+ * on its own line and discard everything from that point to EOF.
267
+ */
268
+ function lastPrivateMarker(output, marker, valuePattern) {
269
+ const re = new RegExp(`(?:^|\\r?\\n)(${marker}${valuePattern})`, "g");
270
+ let match;
271
+ let last;
272
+ while ((match = re.exec(output)) !== null) {
273
+ if (match.index === undefined || match[1] === undefined)
274
+ continue;
275
+ const index = match[0].startsWith("\r\n")
276
+ ? match.index + 2
277
+ : match[0].startsWith("\n")
278
+ ? match.index + 1
279
+ : match.index;
280
+ last = { index, values: match.slice(2) };
281
+ }
282
+ return last;
283
+ }
103
284
  function parseOpenCodeTimeout(output) {
104
285
  const re = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\.[\s\S]*?<\/shell_metadata>\s*$/;
105
286
  const match = re.exec(output);
@@ -107,10 +288,10 @@ function parseOpenCodeTimeout(output) {
107
288
  return undefined;
108
289
  return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
109
290
  }
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]);
291
+ function parseSoftBackgroundOutcome(output, policy) {
292
+ const background = lastPrivateMarker(output, BACKGROUND_MARKER, "(\\d+):([^\\r\\n]+)");
293
+ if (background) {
294
+ const pid = Number(background.values[0]);
114
295
  if (Number.isSafeInteger(pid) && pid > 0 && pid <= 0xffff_ffff) {
115
296
  return {
116
297
  output: withoutMarker(output, background.index),
@@ -126,43 +307,101 @@ function parseWrapperOutcome(output, policy) {
126
307
  };
127
308
  }
128
309
  }
129
- const timeout = new RegExp(`${TIMEOUT_MARKER}(\\d+)\\s*$`).exec(output);
130
- if (timeout?.index !== undefined) {
310
+ const timeout = lastPrivateMarker(output, TIMEOUT_MARKER, "(\\d+)");
311
+ if (timeout) {
131
312
  return {
132
313
  output: withoutMarker(output, timeout.index),
133
- outcome: { kind: "timeout", timeoutMs: Number(timeout[1]) },
314
+ outcome: { kind: "timeout", timeoutMs: Number(timeout.values[0]) },
134
315
  };
135
316
  }
136
- const exit = new RegExp(`${EXIT_MARKER}(-?\\d+)\\s*$`).exec(output);
137
- if (exit?.index !== undefined) {
317
+ const exit = lastPrivateMarker(output, EXIT_MARKER, "(-?\\d+)");
318
+ if (exit) {
138
319
  return {
139
320
  output: withoutMarker(output, exit.index),
140
- outcome: { kind: "exit", code: Number(exit[1]) },
321
+ outcome: { kind: "exit", code: Number(exit.values[0]) },
141
322
  };
142
323
  }
143
324
  return undefined;
144
325
  }
326
+ function parseBackgroundSpawnOutcome(output, policy) {
327
+ const match = lastPrivateMarker(output, BACKGROUND_SHELL_MARKER, "(\\d+):([^\\r\\n]+)");
328
+ if (!match)
329
+ return undefined;
330
+ const pid = Number(match.values[0]);
331
+ if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff)
332
+ return undefined;
333
+ return {
334
+ output: withoutMarker(output, match.index),
335
+ outcome: {
336
+ kind: "backgrounded",
337
+ shellId: pid,
338
+ pid,
339
+ command: policy?.command ?? "",
340
+ workingDirectory: policy?.workingDirectory ?? "",
341
+ msToWait: 0,
342
+ reason: 1,
343
+ },
344
+ };
345
+ }
346
+ /**
347
+ * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
348
+ * Does not record outcomes — use {@link captureCursorShellResult} for that.
349
+ */
350
+ export function sanitizeCursorShellDisplayOutput(output, policy) {
351
+ if (policy?.backgroundSpawn) {
352
+ const spawn = parseBackgroundSpawnOutcome(output, policy);
353
+ if (spawn)
354
+ return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
355
+ }
356
+ if (policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND) {
357
+ const wrapper = parseSoftBackgroundOutcome(output, policy);
358
+ if (wrapper)
359
+ return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
360
+ }
361
+ const timeout = parseOpenCodeTimeout(output);
362
+ if (timeout) {
363
+ return formatShellOutcomeDisplay(timeout.output, {
364
+ kind: "timeout",
365
+ timeoutMs: timeout.timeoutMs,
366
+ });
367
+ }
368
+ return output;
369
+ }
370
+ /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
371
+ export function sanitizeRegisteredCursorShellOutput(toolCallId, output) {
372
+ if (typeof toolCallId !== "string" || !toolCallId)
373
+ return output;
374
+ return sanitizeCursorShellDisplayOutput(output, policies.get(toolCallId));
375
+ }
145
376
  /**
146
377
  * Capture Bash completion in the classic plugin's after hook. Returns the
147
378
  * sanitized output that OpenCode should store and render.
148
379
  */
149
380
  export function captureCursorShellResult(toolCallId, output, metadata) {
150
- if (!toolCallId.startsWith("cursor_"))
381
+ if (typeof toolCallId !== "string" || !toolCallId.startsWith("cursor_"))
151
382
  return output;
152
383
  const policy = policies.get(toolCallId);
384
+ if (policy?.backgroundSpawn) {
385
+ const spawn = parseBackgroundSpawnOutcome(output, policy);
386
+ if (spawn) {
387
+ remember(outcomes, toolCallId, spawn.outcome);
388
+ return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
389
+ }
390
+ }
153
391
  // Private wrapper sentinels are meaningful only for calls we transformed.
154
392
  // A normal foreground command is allowed to print the same text verbatim.
155
393
  const wrapper = policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
156
- ? parseWrapperOutcome(output, policy)
394
+ ? parseSoftBackgroundOutcome(output, policy)
157
395
  : undefined;
158
396
  if (wrapper) {
159
397
  remember(outcomes, toolCallId, wrapper.outcome);
160
- return wrapper.output;
398
+ return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
161
399
  }
162
400
  const timeout = parseOpenCodeTimeout(output);
163
401
  if (timeout) {
164
- remember(outcomes, toolCallId, { kind: "timeout", timeoutMs: timeout.timeoutMs });
165
- return timeout.output;
402
+ const outcome = { kind: "timeout", timeoutMs: timeout.timeoutMs };
403
+ remember(outcomes, toolCallId, outcome);
404
+ return formatShellOutcomeDisplay(timeout.output, outcome);
166
405
  }
167
406
  const exitCode = finiteNonNegative(metadata?.exit);
168
407
  if (exitCode !== undefined)
@@ -171,16 +410,24 @@ export function captureCursorShellResult(toolCallId, output, metadata) {
171
410
  }
172
411
  /** Consume the structured result, with an inline fallback when no plugin hook ran. */
173
412
  export function consumeCursorShellResult(toolCallId, output) {
413
+ if (typeof toolCallId !== "string" || !toolCallId) {
414
+ return { output };
415
+ }
174
416
  let clean = output;
175
417
  if (!outcomes.has(toolCallId))
176
418
  clean = captureCursorShellResult(toolCallId, output);
177
419
  const outcome = outcomes.get(toolCallId);
178
420
  outcomes.delete(toolCallId);
179
421
  policies.delete(toolCallId);
422
+ releaseCursorShellEnv(toolCallId);
180
423
  return { output: clean, outcome };
181
424
  }
182
425
  /** Test/process cleanup. */
183
426
  export function resetCursorShellCalls() {
427
+ for (const wrap of activeEnvWraps.values())
428
+ wrap.cleanup();
429
+ activeEnvWraps.clear();
430
+ pendingEnvWraps.clear();
184
431
  policies.clear();
185
432
  outcomes.clear();
186
433
  }
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.6",
4
4
  "description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
5
5
  "type": "module",
6
6
  "license": "MIT",