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 +5 -5
- package/dist/language-model.d.ts +11 -1
- package/dist/language-model.js +37 -17
- package/dist/plugin.js +25 -3
- package/dist/protocol/messages.js +3 -1
- package/dist/protocol/request.d.ts +2 -0
- package/dist/protocol/request.js +5 -5
- package/dist/protocol/tools.d.ts +2 -2
- package/dist/protocol/tools.js +25 -27
- package/dist/session.d.ts +2 -0
- package/dist/shell-timeout.d.ts +43 -1
- package/dist/shell-timeout.js +268 -21
- package/package.json +1 -1
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.
|
|
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`.
|
|
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,
|
|
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
|
|
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
|
|
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
|
package/dist/language-model.d.ts
CHANGED
|
@@ -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;
|
package/dist/language-model.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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).
|
package/dist/protocol/request.js
CHANGED
|
@@ -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
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -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
|
|
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.
|
|
239
|
-
//
|
|
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 =
|
|
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: {
|
|
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;
|
package/dist/shell-timeout.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/shell-timeout.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
146
|
+
if (!policy)
|
|
147
|
+
return;
|
|
148
|
+
if (policy.backgroundSpawn) {
|
|
149
|
+
pendingEnvWraps.add(toolCallId);
|
|
87
150
|
return;
|
|
88
|
-
|
|
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
|
|
111
|
-
const background =
|
|
112
|
-
if (background
|
|
113
|
-
const pid = Number(background[
|
|
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 =
|
|
130
|
-
if (timeout
|
|
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[
|
|
314
|
+
outcome: { kind: "timeout", timeoutMs: Number(timeout.values[0]) },
|
|
134
315
|
};
|
|
135
316
|
}
|
|
136
|
-
const exit =
|
|
137
|
-
if (exit
|
|
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[
|
|
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
|
-
?
|
|
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
|
-
|
|
165
|
-
|
|
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
|
}
|