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.
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
5
- import { trace } from "./debug.js";
5
+ import { trace, traceRequestContextPaths } from "./debug.js";
6
6
  import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
7
7
  import { decodeFramePayload } from "./protocol/framing.js";
8
8
  import { decodeMessage } from "./protocol/messages.js";
@@ -18,7 +18,8 @@ import { resolveContinuationPolicy, sessionManager, } from "./session.js";
18
18
  import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
19
19
  import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
20
20
  import { buildRequestContext } from "./context/build.js";
21
- import { opencodeGlobalCacheDir } from "./context/paths.js";
21
+ import { workspaceRootFromRequestContext } from "./context/env.js";
22
+ import { opencodeGlobalCacheDir, setHostCacheDirOverride } from "./context/paths.js";
22
23
  import { resolveAgentUrl } from "./agent-url.js";
23
24
  import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
24
25
  import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
@@ -224,6 +225,9 @@ function rememberPostCompactionRebase(sessionKey) {
224
225
  }
225
226
  }
226
227
  export function createCursorLanguageModel(modelId, providerId, options) {
228
+ // Host Path.cache / explicit override wins over XDG heuristics for this process.
229
+ if (options.cacheDir)
230
+ setHostCacheDirOverride(options.cacheDir);
227
231
  return {
228
232
  specificationVersion: "v3",
229
233
  provider: providerId,
@@ -288,7 +292,7 @@ async function doStreamImpl(modelId, options, callOptions) {
288
292
  // Cursor can continue instead of deadlocking.
289
293
  const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
290
294
  trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] — rebasing fresh Run`);
291
- session = await openSession({ recovery: true });
295
+ session = await openSession({ recovery: { kind: "rebase" } });
292
296
  }
293
297
  else {
294
298
  // Fresh turn (prompt ends with user/assistant text). Historical tool
@@ -323,7 +327,7 @@ async function doStreamImpl(modelId, options, callOptions) {
323
327
  abortSignal: callOptions.abortSignal,
324
328
  promptTokens,
325
329
  retryPolicy,
326
- recover: () => openSession({ recovery: true }),
330
+ recover: (recovery) => openSession({ recovery }),
327
331
  onSession: (next) => { activeSession = next; },
328
332
  });
329
333
  try {
@@ -361,6 +365,7 @@ export async function pumpWithRecovery(input) {
361
365
  maxAttempts: (input.maxRecoveries ?? 1) + 1,
362
366
  };
363
367
  const maxRecoveries = retryPolicy.maxAttempts - 1;
368
+ const requestUsage = { outputChars: 0 };
364
369
  input.onSession?.(session);
365
370
  for (let attempt = 0;; attempt++) {
366
371
  const pumpedSession = session;
@@ -371,6 +376,7 @@ export async function pumpWithRecovery(input) {
371
376
  textId: crypto.randomUUID(),
372
377
  reasoningId: crypto.randomUUID(),
373
378
  promptTokens: input.promptTokens ?? 0,
379
+ requestUsage,
374
380
  }, input.abortSignal);
375
381
  return session;
376
382
  }
@@ -381,19 +387,26 @@ export async function pumpWithRecovery(input) {
381
387
  });
382
388
  if (!failure.transient)
383
389
  throw failure;
384
- if (!failure.replaySafe) {
390
+ const checkpoint = pumpedSession.resumeCheckpoint;
391
+ if (!failure.replaySafe && !checkpoint) {
385
392
  throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
386
393
  }
387
394
  if (attempt >= maxRecoveries) {
388
395
  throw new CursorRetryExhaustedError(attempt + 1, failure);
389
396
  }
390
397
  trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
391
- `err=${failure.message} — rebasing fresh Run`);
398
+ `err=${failure.message} — ${checkpoint ? `resuming ${checkpoint.length}B checkpoint` : "rebasing fresh Run"}`);
392
399
  sessionManager.close(pumpedSession, "remote-error", failure);
393
400
  const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
394
401
  trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
395
402
  await sleepForRetry(delayMs, input.abortSignal);
396
- session = await input.recover();
403
+ session = await input.recover(checkpoint
404
+ ? {
405
+ kind: "resume",
406
+ conversationId: pumpedSession.conversationId,
407
+ checkpoint: Uint8Array.from(checkpoint),
408
+ }
409
+ : { kind: "rebase" });
397
410
  input.onSession?.(session);
398
411
  }
399
412
  finally {
@@ -419,18 +432,21 @@ async function startSession(modelId, token, callOptions, options, startOptions)
419
432
  const tools = toolState.advertisedTools;
420
433
  const allowTools = toolState.allowTools;
421
434
  const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
422
- const recovery = startOptions?.recovery === true;
435
+ const recovery = startOptions?.recovery;
436
+ const resuming = recovery?.kind === "resume";
423
437
  // Compaction must not reuse the prior conversation; its first normal turn
424
438
  // must also rebase so the summary-agent checkpoint cannot replace the normal
425
439
  // system prompt and OpenCode's newly compacted history.
426
- const bound = bindConversationId(sessionKey, { reset: resetState.reset || recovery });
440
+ const bound = resuming
441
+ ? { conversationId: recovery.conversationId, reset: false, previousId: undefined }
442
+ : bindConversationId(sessionKey, { reset: resetState.reset || recovery?.kind === "rebase" });
427
443
  const conversationId = bound.conversationId;
428
444
  if (bound.reset) {
429
- trace(`conversation reset: reason=${recovery ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
445
+ trace(`conversation reset: reason=${recovery?.kind === "rebase" ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
430
446
  `sessionKey=${sessionKey ?? "(none)"} ` +
431
447
  `previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
432
448
  }
433
- const userText = recovery
449
+ const userText = recovery?.kind === "rebase"
434
450
  ? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
435
451
  : (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
436
452
  const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
@@ -440,8 +456,8 @@ async function startSession(modelId, token, callOptions, options, startOptions)
440
456
  ? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
441
457
  : baseSystemPrompt;
442
458
  const history = extractPromptHistory(prompt, {
443
- preserveTrailingUser: recovery,
444
- toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
459
+ preserveTrailingUser: recovery?.kind === "rebase",
460
+ toolResults: isCompaction ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"),
445
461
  });
446
462
  await loadAvailableModels();
447
463
  // Resolve the region-specific Run stream origin once per process (memoized
@@ -485,7 +501,9 @@ async function startSession(modelId, token, callOptions, options, startOptions)
485
501
  : [];
486
502
  // CLI parity: echo the last conversation_checkpoint_update as conversation_state.
487
503
  // After compaction reset there is no checkpoint — seed from OpenCode history.
488
- const conversationState = bound.reset ? undefined : getCheckpoint(conversationId);
504
+ const conversationState = resuming
505
+ ? recovery.checkpoint
506
+ : (bound.reset ? undefined : getCheckpoint(conversationId));
489
507
  const reqBytes = buildRunRequest({
490
508
  text: userText,
491
509
  modelId: cursorModelId,
@@ -498,6 +516,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
498
516
  tools,
499
517
  toolDescriptors,
500
518
  requestContext,
519
+ action: resuming ? "resume" : "user",
501
520
  });
502
521
  // Content hashes — Cursor content-addresses large payloads; logging these lets
503
522
  // us match a server get_blob_args.blob_id to what it wants served.
@@ -523,6 +542,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
523
542
  `availableModels=${_availableModels?.length ?? 0} userTextLen=${userText.length} ` +
524
543
  `historyMsgs=${history.length} historyChars=${historyChars} ` +
525
544
  `checkpointLen=${conversationState?.length ?? 0} reset=${bound.reset} ` +
545
+ `resume=${resuming} ` +
526
546
  `usageEstimateIn=${usageEstimate.inputTokens} runRequestBytes=${reqBytes.length}`);
527
547
  if (hooksCtx)
528
548
  trace(`outbound Run hooks_additional_context: ${hooksCtx}`);
@@ -541,6 +561,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
541
561
  const session = {
542
562
  sessionId: crypto.randomUUID(),
543
563
  conversationId,
564
+ resumeCheckpoint: undefined,
544
565
  openCodeSessionId: sessionKey,
545
566
  stream,
546
567
  frames: stream.frames()[Symbol.asyncIterator](),
@@ -646,6 +667,7 @@ export function deliverContinuationResults(session, trailingToolResults) {
646
667
  if (!pending.bridged) {
647
668
  try {
648
669
  const shellResult = pending.resultField === "shell_stream"
670
+ || pending.resultField === "background_shell_spawn_result"
649
671
  ? consumeCursorShellResult(r.toolCallId, r.output)
650
672
  : undefined;
651
673
  frames = buildExecClientMessages({
@@ -795,7 +817,7 @@ export async function pump(session, controller, ids, abortSignal) {
795
817
  const advertisedToolNameSet = new Set(advertisedToolNames);
796
818
  let textStarted = false;
797
819
  let reasoningStarted = false;
798
- let emittedOutputChars = 0;
820
+ const requestUsage = ids.requestUsage ?? { outputChars: 0 };
799
821
  let replaySafe = true;
800
822
  // OpenCode cancels the ReadableStream between turns (see the cancel handler
801
823
  // in doStreamImpl). The frames iterator can still yield a final `done` after
@@ -871,9 +893,9 @@ export async function pump(session, controller, ids, abortSignal) {
871
893
  const editPath = typeof display.args.path === "string" ? display.args.path : "";
872
894
  if (!requestedPath || !editPath)
873
895
  return false;
874
- const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
875
- ? session.requestContext.workspace_project_dir
876
- : process.cwd();
896
+ // Prefer env.workspace_paths project_folder / workspace_project_dir are
897
+ // Cursor metadata roots under <host-cache>/projects/, not the git tree.
898
+ const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
877
899
  const resolvePath = (value) => path.resolve(workspaceRoot, value);
878
900
  const absolutePath = resolvePath(requestedPath);
879
901
  if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
@@ -923,7 +945,7 @@ export async function pump(session, controller, ids, abortSignal) {
923
945
  }
924
946
  session.usageEstimate.outputTokens += estimateTokens(text.length);
925
947
  if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
926
- emittedOutputChars += text.length;
948
+ requestUsage.outputChars += text.length;
927
949
  }
928
950
  };
929
951
  const emitReasoning = (text) => {
@@ -936,7 +958,7 @@ export async function pump(session, controller, ids, abortSignal) {
936
958
  }
937
959
  session.usageEstimate.outputTokens += estimateTokens(text.length);
938
960
  if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
939
- emittedOutputChars += text.length;
961
+ requestUsage.outputChars += text.length;
940
962
  }
941
963
  };
942
964
  const emitFinish = (te, reason) => {
@@ -959,7 +981,7 @@ export async function pump(session, controller, ids, abortSignal) {
959
981
  cacheWrite: 0,
960
982
  },
961
983
  outputTokens: {
962
- total: estimateTokens(emittedOutputChars),
984
+ total: estimateTokens(requestUsage.outputChars),
963
985
  text: undefined,
964
986
  reasoning: undefined,
965
987
  },
@@ -1097,6 +1119,7 @@ export async function pump(session, controller, ids, abortSignal) {
1097
1119
  const bytes = normalizeCheckpointBytes(checkpointRaw);
1098
1120
  if (bytes && bytes.length > 0) {
1099
1121
  setCheckpoint(session.conversationId, bytes);
1122
+ session.resumeCheckpoint = Uint8Array.from(bytes);
1100
1123
  trace(`checkpoint: stored ${bytes.length}B for conversationId=${session.conversationId}`);
1101
1124
  }
1102
1125
  }
@@ -1202,6 +1225,7 @@ export async function pump(session, controller, ids, abortSignal) {
1202
1225
  trace(`exec request_context hooks_additional_context: ${hooks}`);
1203
1226
  }
1204
1227
  try {
1228
+ traceRequestContextPaths(`exec request_context reply id=${esmId}`, session.requestContext);
1205
1229
  session.stream.write(buildRequestContextResult(esmId, session.requestContext));
1206
1230
  trace(`exec request_context: replied`);
1207
1231
  }
@@ -1279,7 +1303,8 @@ export async function pump(session, controller, ids, abortSignal) {
1279
1303
  trace(`exec: claimed display callId=${displayCallId}`);
1280
1304
  }
1281
1305
  const tc = buildToolCallPart(parsed, session.sessionId);
1282
- if (parsed.resultField === "shell_stream") {
1306
+ if (parsed.resultField === "shell_stream"
1307
+ || parsed.resultField === "background_shell_spawn_result") {
1283
1308
  registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
1284
1309
  }
1285
1310
  // Keep the stream open; the result arrives on the next doStream call.
package/dist/plugin-v2.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { define } from "@opencode-ai/plugin/v2/promise";
2
2
  import { CURSOR_PROVIDER_ID } from "./shared.js";
3
3
  import { createCursorLanguageModel } from "./language-model.js";
4
+ import { adoptCompatHostCacheDir } from "./context/paths.js";
4
5
  /**
5
6
  * OpenCode Effect / Promise v2 plugin.
6
7
  *
@@ -25,6 +26,9 @@ function createSdk(options) {
25
26
  export default define({
26
27
  id: "cursor.provider",
27
28
  setup: async (ctx) => {
29
+ // Prefer OCP HostProfile.cacheDir when present; else XDG host heuristic.
30
+ // Explicit createCursor({ cacheDir }) / event.options.cacheDir still wins via LM.
31
+ await adoptCompatHostCacheDir();
28
32
  await ctx.aisdk.sdk((event) => {
29
33
  if (event.sdk)
30
34
  return;
package/dist/plugin.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
2
2
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
3
3
  import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
4
- import { opencodeGlobalCacheDir } from "./context/paths.js";
4
+ import { adoptCompatHostCacheDir, 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, setCursorShellPath, } 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
  /**
@@ -194,6 +194,8 @@ function cursorGetServerConfigTelemetryEnabled() {
194
194
  process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
195
195
  }
196
196
  export async function CursorPlugin(input) {
197
+ // Prefer OCP HostProfile.cacheDir when the compat package is present; else XDG heuristic.
198
+ await adoptCompatHostCacheDir();
197
199
  const cacheDir = opencodeGlobalCacheDir();
198
200
  const apiBaseURL = cursorApiBaseURL();
199
201
  // Last access token successfully resolved in this plugin instance. Config's
@@ -336,13 +338,35 @@ export async function CursorPlugin(input) {
336
338
  async "tool.execute.before"(hookInput, output) {
337
339
  if (hookInput.tool !== "bash")
338
340
  return;
341
+ // bash/zsh retain the original display/permission command and wrap via
342
+ // shell.env. sh/dash need a short wrapper-file command because their
343
+ // non-interactive `-c` path ignores BASH_ENV / ZDOTDIR.
339
344
  prepareCursorShellArgs(hookInput.callID, output.args);
340
345
  },
346
+ async "shell.env"(hookInput, output) {
347
+ const env = cursorShellEnvForCall(hookInput.callID);
348
+ if (!env)
349
+ return;
350
+ Object.assign(output.env, env);
351
+ },
341
352
  async "tool.execute.after"(hookInput, output) {
342
353
  if (hookInput.tool !== "bash")
343
354
  return;
344
- output.title = cursorShellOriginalCommand(hookInput.callID) ?? output.title;
345
- output.output = captureCursorShellResult(hookInput.callID, output.output, output.metadata);
355
+ try {
356
+ output.title = cursorShellOriginalCommand(hookInput.callID) ?? output.title;
357
+ output.output = captureCursorShellResult(hookInput.callID, output.output, output.metadata);
358
+ // OpenCode's bash GUI falls back to metadata.output when output is empty
359
+ // (`props.output || props.metadata.output`), so strip private markers there too.
360
+ if (output.metadata && typeof output.metadata === "object") {
361
+ const metadata = output.metadata;
362
+ if (typeof metadata.output === "string") {
363
+ metadata.output = sanitizeRegisteredCursorShellOutput(hookInput.callID, metadata.output);
364
+ }
365
+ }
366
+ }
367
+ finally {
368
+ releaseCursorShellEnv(hookInput.callID);
369
+ }
346
370
  },
347
371
  async "chat.params"(hookInput, output) {
348
372
  if (hookInput.model.providerID !== CURSOR_PROVIDER_ID)
@@ -355,6 +379,7 @@ export async function CursorPlugin(input) {
355
379
  }
356
380
  },
357
381
  async config(cfg) {
382
+ setCursorShellPath(cfg.shell);
358
383
  cfg.provider ??= {};
359
384
  const models = await loadModels();
360
385
  const existing = cfg.provider[CURSOR_PROVIDER_ID];
@@ -464,6 +489,7 @@ export async function CursorPlugin(input) {
464
489
  return {
465
490
  ...(accessToken ? { accessToken } : {}),
466
491
  workspaceRoot: input.directory,
492
+ cacheDir,
467
493
  };
468
494
  },
469
495
  },
@@ -653,8 +653,10 @@ export function createMessageTypes() {
653
653
  { id: 2, name: "workspace_paths", type: "string", repeated: true },
654
654
  { id: 3, name: "shell", type: "string" },
655
655
  { id: 5, name: "sandbox_enabled", type: "bool" },
656
+ { id: 7, name: "terminals_folder", type: "string" },
656
657
  { id: 10, name: "time_zone", type: "string" },
657
658
  { id: 11, name: "project_folder", type: "string" },
659
+ { id: 12, name: "agent_transcripts_folder", type: "string" },
658
660
  { id: 14, name: "sandbox_supported", type: "bool" },
659
661
  { id: 20, name: "is_working_dir_home_dir", type: "bool" },
660
662
  { id: 21, name: "process_working_directory", type: "string" },
@@ -903,7 +905,9 @@ export function createMessageTypes() {
903
905
  { id: 2, name: "request_context", type: "RequestContext" },
904
906
  ]);
905
907
  addType(root, "ResumeAction", [
906
- { id: 1, name: "conversation_id", type: "string" },
908
+ // Cursor CLI sends an empty ResumeAction. Field #2 is available for a
909
+ // refreshed RequestContext; the conversation id belongs to AgentRunRequest.
910
+ { id: 2, name: "request_context", type: "RequestContext" },
907
911
  ]);
908
912
  addType(root, "CancelAction", [
909
913
  { 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;
@@ -1,8 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import { encodeMessage } from "./messages.js";
3
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
4
- import { trace } from "../debug.js";
4
+ import { buildEnv } from "../context/env.js";
5
+ import { ensureOpencodeProjectDir } from "../context/paths.js";
6
+ import { trace, traceRequestContextPaths } from "../debug.js";
5
7
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
8
+ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
6
9
  // Exec variant field number whose reply is the server-initiated request_context
7
10
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
8
11
  // field number for every exec variant, so this is also the result field.
@@ -103,19 +106,12 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
103
106
  const flat = toolsToDescriptors(tools, providerIdentifier, knownMcpServers);
104
107
  const nested = toolsToMcpDescriptors(tools, providerIdentifier, knownMcpServers);
105
108
  const cwd = process.cwd();
106
- return {
107
- env: {
108
- os_version: process.platform,
109
- workspace_paths: [cwd],
110
- shell: process.env.SHELL || "/bin/bash",
111
- time_zone: "UTC",
112
- project_folder: cwd,
113
- process_working_directory: cwd,
114
- },
109
+ const ctx = {
110
+ env: buildEnv(cwd),
115
111
  tools: flat,
116
112
  mcp_file_system_options: {
117
113
  enabled: true,
118
- workspace_project_dir: cwd,
114
+ workspace_project_dir: ensureOpencodeProjectDir(cwd),
119
115
  mcp_descriptors: nested,
120
116
  },
121
117
  mcp_meta_tool_options: {
@@ -128,6 +124,8 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
128
124
  mcp_file_system_info_complete: true,
129
125
  git_status_info_complete: true,
130
126
  };
127
+ traceRequestContextPaths("buildLiveRequestContext", ctx);
128
+ return ctx;
131
129
  }
132
130
  // ── Cursor exec-variant → opencode tool name ──
133
131
  // Native ExecServerMessage variants only (agent.v1). There is no edit_args or
@@ -137,7 +135,7 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
137
135
  // Design note (F11): Cursor has native delete / background-shell tools; OpenCode
138
136
  // does not. This provider intentionally remaps:
139
137
  // - delete_args → bash `rm -f -- <quoted-path>`
140
- // - background_shell_spawn_args → bash + nohup detach wrapper
138
+ // - background_shell_spawn_args → bash (original command; plugin wraps nohup)
141
139
  // Permissions still flow through OpenCode's advertised `bash` tool. Soft-
142
140
  // background / detached children can outlive the OpenCode tool call; leftover
143
141
  // process cleanup is left to the user / OS. Arg key remapping happens below.
@@ -213,7 +211,6 @@ export function mapExecServerToToolName(execField) {
213
211
  export function mapToolNameToExecField(toolName) {
214
212
  return opencodeToolToCursor[toolName];
215
213
  }
216
- const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
217
214
  export function parseExecServerMessage(msg) {
218
215
  const id = msg.id;
219
216
  if (id === undefined)
@@ -235,9 +232,10 @@ export function parseExecServerMessage(msg) {
235
232
  if (!resultField)
236
233
  return undefined;
237
234
  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).
240
- // Residual: the child can keep running after this tool call completes.
235
+ // F11: Cursor native background shell → OpenCode bash. Keep the wrapper
236
+ // self-contained so direct provider / hosts without shell.env still detach
237
+ // and return a PID. The classic plugin replaces this with its display-safe
238
+ // env or wrapper-file path before execution.
241
239
  if (execVariant === "background_shell_spawn_args") {
242
240
  const raw = msg.background_shell_spawn_args ?? {};
243
241
  const command = str(raw.command);
@@ -253,7 +251,11 @@ export function parseExecServerMessage(msg) {
253
251
  toolName: "bash",
254
252
  args,
255
253
  resultField,
256
- resultMetadata: { command: command ?? "", working_directory: workingDirectory },
254
+ resultMetadata: {
255
+ background_shell_spawn: true,
256
+ command: command ?? "",
257
+ working_directory: workingDirectory,
258
+ },
257
259
  localError: raw.enable_write_shell_stdin_tool === true
258
260
  ? "Interactive background shells are not available through OpenCode's bash tool."
259
261
  : command
@@ -540,25 +542,6 @@ function mapCursorSubagentTypeToOpenCode(subagentType) {
540
542
  function shellQuote(s) {
541
543
  return `'${s.replace(/'/g, `'\\''`)}'`;
542
544
  }
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
545
  /**
563
546
  * Map Cursor McpArgs back to the OpenCode tool id.
564
547
  * Prefers provider_identifier + bare tool_name (github + create_pull_request
@@ -669,7 +652,7 @@ export function buildExecClientMessages(input) {
669
652
  id: input.execId,
670
653
  local_execution_time_ms: input.executionTimeMs ?? 0,
671
654
  };
672
- clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata);
655
+ clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome);
673
656
  frames.push(encodeMessage("AgentClientMessage", {
674
657
  exec_client_message: clientMsg,
675
658
  }));
@@ -756,7 +739,7 @@ export function unwrapReadOutput(output) {
756
739
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
757
740
  * server accepts (verified against agent.v1 wire captures).
758
741
  */
759
- export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
742
+ export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome) {
760
743
  switch (resultField) {
761
744
  case "read_result": {
762
745
  const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
@@ -841,6 +824,19 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
841
824
  const workingDirectory = str(resultMetadata?.working_directory) ?? "";
842
825
  if (error)
843
826
  return { error: { command, working_directory: workingDirectory, error } };
827
+ // Prefer the structured outcome captured by the plugin after-hook; markers
828
+ // are already stripped from the stored/rendered OpenCode output by then.
829
+ if (shellOutcome?.kind === "backgrounded") {
830
+ return {
831
+ success: {
832
+ shell_id: shellOutcome.shellId,
833
+ command: shellOutcome.command || command,
834
+ working_directory: shellOutcome.workingDirectory || workingDirectory,
835
+ pid: shellOutcome.pid,
836
+ },
837
+ };
838
+ }
839
+ // Fallback when no plugin hook ran: parse the private spawn marker inline.
844
840
  const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):([^\\r\\n]+)`).exec(output);
845
841
  const pid = match ? Number(match[1]) : 0;
846
842
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
@@ -1114,6 +1110,7 @@ export function detectExecVariantField(agentServerPayload) {
1114
1110
  * Encode exec #10 request_context_result from a prebuilt RequestContext payload.
1115
1111
  */
1116
1112
  export function buildRequestContextResult(execId, requestContext) {
1113
+ traceRequestContextPaths(`buildRequestContextResult id=${execId}`, requestContext);
1117
1114
  return encodeMessage("AgentClientMessage", {
1118
1115
  exec_client_message: {
1119
1116
  id: execId,
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;