cursor-opencode-provider 0.8.0 → 0.8.3
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/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +129 -10
- package/dist/session.js +198 -35
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
package/dist/shell-timeout.js
CHANGED
|
@@ -1,26 +1,107 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { basename, delimiter, join } from "node:path";
|
|
3
|
+
import { basename, delimiter, dirname as pathDirectory, join, resolve } from "node:path";
|
|
4
|
+
import { releaseUnstartedTerminal } from "./terminal-runner.js";
|
|
5
|
+
import { processShared } from "./process-shared.js";
|
|
4
6
|
/** Cursor agent.v1 TimeoutBehavior enum values. */
|
|
5
7
|
export const CURSOR_TIMEOUT_CANCEL = 1;
|
|
6
8
|
export const CURSOR_TIMEOUT_BACKGROUND = 2;
|
|
7
9
|
const MAX_TRACKED_SHELL_CALLS = 512;
|
|
8
10
|
const OPENCODE_TIMEOUT_GRACE_MS = 15_000;
|
|
9
11
|
const POLL_INTERVAL_MS = 100;
|
|
12
|
+
/** Past the window, a hard-timed-out command gets the watchdog's 3 s TERM grace plus slack. */
|
|
13
|
+
const TIMEOUT_SETTLE_MS = 4_000;
|
|
10
14
|
const BACKGROUND_MARKER = "__CURSOR_SHELL_BACKGROUND__";
|
|
11
15
|
const EXIT_MARKER = "__CURSOR_SHELL_EXIT__";
|
|
12
16
|
const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
|
|
13
17
|
/** Private marker for Cursor `background_shell_spawn_args` detach wrappers. */
|
|
14
18
|
export const BACKGROUND_SHELL_MARKER = "__CURSOR_BACKGROUND_SHELL__";
|
|
15
|
-
|
|
16
|
-
const
|
|
19
|
+
// Native plugin and SDK entrypoints can live in separate module graphs.
|
|
20
|
+
const policies = processShared("shell-policies.v1", () => new Map());
|
|
21
|
+
const outcomes = processShared("shell-outcomes.v1", () => new Map());
|
|
22
|
+
const failures = processShared("shell-failures.v1", () => new Map());
|
|
17
23
|
/** callIDs that need shell.env injectors or a direct-command fallback. */
|
|
18
|
-
const pendingEnvWraps = new Set();
|
|
19
|
-
const activeEnvWraps = new Map();
|
|
20
|
-
|
|
24
|
+
const pendingEnvWraps = processShared("shell-pending-env.v1", () => new Set());
|
|
25
|
+
const activeEnvWraps = processShared("shell-active-env.v1", () => new Map());
|
|
26
|
+
const createGate = processShared("shell-create-gate.v2", () => new Map());
|
|
27
|
+
/**
|
|
28
|
+
* The `execute.before` event of each armed call. OpenCode creates the shell
|
|
29
|
+
* from `event.input` after every hook ran, so its current command is the one
|
|
30
|
+
* the call's create carries, even when a later hook rewrote it.
|
|
31
|
+
*/
|
|
32
|
+
const armedCalls = processShared("shell-create-calls.v1", () => new Map());
|
|
33
|
+
const shellConfiguration = processShared("shell-configuration.v1", () => ({
|
|
34
|
+
defaultShell: undefined,
|
|
35
|
+
directories: new Map(),
|
|
36
|
+
}));
|
|
37
|
+
const configuredShells = shellConfiguration.directories;
|
|
38
|
+
/**
|
|
39
|
+
* Serialize OpenCode 2 shell creates onto their owning tool call. Call from
|
|
40
|
+
* `execute.before` after `prepareCursorShellArgs`, passing the hook event the
|
|
41
|
+
* host later executes. Every call arms, so an ordinary call cannot take a
|
|
42
|
+
* wrapped call's adapter; `prepareCursorShellCreate` releases the holder at
|
|
43
|
+
* its create. Only a create whose command a `create.before` hook rewrote
|
|
44
|
+
* cannot be attributed; that call keeps its own command's lane until
|
|
45
|
+
* `execute.after` or its failure.
|
|
46
|
+
*/
|
|
47
|
+
export async function armCursorShellCreate(toolCallId, call) {
|
|
48
|
+
if (process.platform === "win32")
|
|
49
|
+
return;
|
|
50
|
+
const command = callCommand(call);
|
|
51
|
+
if (command === undefined)
|
|
52
|
+
return;
|
|
53
|
+
armedCalls.set(toolCallId, call);
|
|
54
|
+
const lane = createGate.get(command);
|
|
55
|
+
if (!lane) {
|
|
56
|
+
createGate.set(command, { lockedBy: toolCallId, waiters: [] });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
lane.waiters.push({ id: toolCallId, resolve, reject });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// resetCursorShellCalls cancelled this waiter.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Release a create-before arm when the call is claimed, fails, or finishes. */
|
|
69
|
+
export function disarmCursorShellCreate(toolCallId) {
|
|
70
|
+
armedCalls.delete(toolCallId);
|
|
71
|
+
for (const [command, lane] of createGate) {
|
|
72
|
+
lane.waiters = lane.waiters.filter((waiter) => waiter.id !== toolCallId);
|
|
73
|
+
if (lane.lockedBy !== toolCallId)
|
|
74
|
+
continue;
|
|
75
|
+
const next = lane.waiters.shift();
|
|
76
|
+
if (!next) {
|
|
77
|
+
createGate.delete(command);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Hand over before the waiter resumes, so releasing it in between still
|
|
81
|
+
// passes the lane on.
|
|
82
|
+
lane.lockedBy = next.id;
|
|
83
|
+
next.resolve();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function callCommand(call) {
|
|
87
|
+
const input = call?.input;
|
|
88
|
+
if (typeof input !== "object" || input === null)
|
|
89
|
+
return undefined;
|
|
90
|
+
const command = input.command;
|
|
91
|
+
return typeof command === "string" ? command : undefined;
|
|
92
|
+
}
|
|
21
93
|
/** Track OpenCode's configured shell from the classic config hook. */
|
|
22
|
-
export function setCursorShellPath(shell) {
|
|
23
|
-
|
|
94
|
+
export function setCursorShellPath(shell, directory) {
|
|
95
|
+
const selected = shell?.trim() || undefined;
|
|
96
|
+
if (!directory) {
|
|
97
|
+
shellConfiguration.defaultShell = selected;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const root = canonicalDirectory(directory);
|
|
101
|
+
configuredShells.delete(root);
|
|
102
|
+
configuredShells.set(root, selected);
|
|
103
|
+
while (configuredShells.size > 512)
|
|
104
|
+
configuredShells.delete(configuredShells.keys().next().value);
|
|
24
105
|
}
|
|
25
106
|
function executableOnPath(name) {
|
|
26
107
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
@@ -29,11 +110,40 @@ function executableOnPath(name) {
|
|
|
29
110
|
}
|
|
30
111
|
return false;
|
|
31
112
|
}
|
|
113
|
+
/** The executable selected by native OpenCode config, with its POSIX fallback. */
|
|
114
|
+
export function resolveCursorShellPath(shell, directory) {
|
|
115
|
+
const root = directory && canonicalDirectory(directory);
|
|
116
|
+
const configured = root && configuredShells.has(root) ? configuredShells.get(root) : shellConfiguration.defaultShell;
|
|
117
|
+
shell ??= configured ?? process.env.SHELL;
|
|
118
|
+
const name = shell ? basename(shell).toLowerCase() : "";
|
|
119
|
+
if (shell && name !== "fish" && name !== "nu") {
|
|
120
|
+
if (shell.includes("/")) {
|
|
121
|
+
try {
|
|
122
|
+
if (statSync(shell).isFile())
|
|
123
|
+
return shell;
|
|
124
|
+
}
|
|
125
|
+
catch { /* native fallback */ }
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
129
|
+
if (dir && existsSync(join(dir, shell)))
|
|
130
|
+
return join(dir, shell);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (process.platform === "darwin")
|
|
135
|
+
return "/bin/zsh";
|
|
136
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
137
|
+
if (dir && existsSync(join(dir, "bash")))
|
|
138
|
+
return join(dir, "bash");
|
|
139
|
+
}
|
|
140
|
+
return "/bin/sh";
|
|
141
|
+
}
|
|
32
142
|
/**
|
|
33
143
|
* Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
|
|
34
144
|
* then POSIX falls back to bash when installed and `/bin/sh` otherwise.
|
|
35
145
|
*/
|
|
36
|
-
export function resolveCursorShellKind(shell =
|
|
146
|
+
export function resolveCursorShellKind(shell = shellConfiguration.defaultShell ?? process.env.SHELL) {
|
|
37
147
|
let name = shell ? basename(shell).toLowerCase().replace(/\.exe$/, "") : "";
|
|
38
148
|
if (name === "fish" || name === "nu" || !name) {
|
|
39
149
|
name = executableOnPath("bash") ? "bash" : "sh";
|
|
@@ -64,6 +174,13 @@ function finiteNonNegative(value) {
|
|
|
64
174
|
export function shellPolicyFromMetadata(metadata) {
|
|
65
175
|
if (!metadata)
|
|
66
176
|
return undefined;
|
|
177
|
+
const managed = {
|
|
178
|
+
...(typeof metadata.description === "string" ? { description: metadata.description } : {}),
|
|
179
|
+
...(typeof metadata.managed_command === "string" ? { managedCommand: metadata.managed_command } : {}),
|
|
180
|
+
...(typeof metadata.managed_job_directory === "string" ? { managedJobDirectory: metadata.managed_job_directory } : {}),
|
|
181
|
+
...(typeof metadata.managed_shell_path === "string" ? { shellPath: metadata.managed_shell_path } : {}),
|
|
182
|
+
};
|
|
183
|
+
const hardTimeoutMs = finiteNonNegative(metadata.hard_timeout_ms);
|
|
67
184
|
if (metadata.background_shell_spawn === true) {
|
|
68
185
|
return {
|
|
69
186
|
command: typeof metadata.command === "string" ? metadata.command : "",
|
|
@@ -71,19 +188,21 @@ export function shellPolicyFromMetadata(metadata) {
|
|
|
71
188
|
timeoutMs: 0,
|
|
72
189
|
timeoutBehavior: 0,
|
|
73
190
|
backgroundSpawn: true,
|
|
191
|
+
...(hardTimeoutMs !== undefined && hardTimeoutMs > 0 ? { hardTimeoutMs } : {}),
|
|
192
|
+
...managed,
|
|
74
193
|
};
|
|
75
194
|
}
|
|
76
195
|
if (metadata.shell_stream !== true)
|
|
77
196
|
return undefined;
|
|
78
197
|
const timeoutMs = finiteNonNegative(metadata.timeout_ms) ?? 30_000;
|
|
79
198
|
const timeoutBehavior = finiteNonNegative(metadata.timeout_behavior) ?? 0;
|
|
80
|
-
const hardTimeoutMs = finiteNonNegative(metadata.hard_timeout_ms);
|
|
81
199
|
return {
|
|
82
200
|
command: typeof metadata.command === "string" ? metadata.command : "",
|
|
83
201
|
workingDirectory: typeof metadata.working_directory === "string" ? metadata.working_directory : "",
|
|
84
202
|
timeoutMs,
|
|
85
203
|
timeoutBehavior,
|
|
86
204
|
...(hardTimeoutMs !== undefined && hardTimeoutMs > 0 ? { hardTimeoutMs } : {}),
|
|
205
|
+
...managed,
|
|
87
206
|
};
|
|
88
207
|
}
|
|
89
208
|
/** Register a Cursor shell request before OpenCode executes its emitted tool call. */
|
|
@@ -91,7 +210,8 @@ export function registerCursorShellCall(toolCallId, metadata) {
|
|
|
91
210
|
const policy = shellPolicyFromMetadata(metadata);
|
|
92
211
|
if (!policy || !toolCallId.startsWith("cursor_"))
|
|
93
212
|
return;
|
|
94
|
-
|
|
213
|
+
// A call evicted before the host ran it must not keep its reserved record.
|
|
214
|
+
remember(policies, toolCallId, policy, (evicted) => releaseUnstartedTerminal(evicted.managedJobDirectory));
|
|
95
215
|
}
|
|
96
216
|
function shellQuote(value) {
|
|
97
217
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
@@ -114,8 +234,10 @@ export function buildSoftBackgroundCommand(policy) {
|
|
|
114
234
|
? Math.max(1, Math.ceil(policy.hardTimeoutMs / POLL_INTERVAL_MS))
|
|
115
235
|
: undefined;
|
|
116
236
|
const lines = [
|
|
237
|
+
`cursor_payload_shell=${shellQuote(policy.shellPath ?? resolveCursorShellPath())}`,
|
|
238
|
+
'[ -z "$CURSOR_SHELL_EXECUTABLE" ] || cursor_payload_shell="$CURSOR_SHELL_EXECUTABLE"',
|
|
117
239
|
'cursor_shell_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-shell.XXXXXX")" || exit 1',
|
|
118
|
-
`nohup
|
|
240
|
+
`nohup "$cursor_payload_shell" -c ${shellQuote(policy.command)} >"$cursor_shell_log" 2>&1 </dev/null &`,
|
|
119
241
|
"cursor_shell_pid=$!",
|
|
120
242
|
];
|
|
121
243
|
if (hardPolls !== undefined) {
|
|
@@ -128,10 +250,19 @@ export function buildSoftBackgroundCommand(policy) {
|
|
|
128
250
|
// Avoid interactive job-control noise ("Terminated: 15 …") when we later
|
|
129
251
|
// reap the watchdog; that text can otherwise land after our private marker
|
|
130
252
|
// and leak into OpenCode's bash UI.
|
|
131
|
-
"set +m 2>/dev/null || true",
|
|
132
|
-
//
|
|
133
|
-
// shell
|
|
134
|
-
|
|
253
|
+
"set +m 2>/dev/null || true",
|
|
254
|
+
// The payload stays a job so `wait` can return its status (bash discards
|
|
255
|
+
// a disowned job's). From here this shell's stderr carries only job
|
|
256
|
+
// notices; the payload and watchdog already write elsewhere.
|
|
257
|
+
"exec 2>/dev/null", 'if [ -n "$cursor_shell_watchdog_pid" ]; then disown "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', 'cursor_shell_timed_out() { [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; }', "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', " if ! cursor_shell_timed_out; then", ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", " fi",
|
|
258
|
+
// A hard-timed-out command is being stopped, not running on: never hand
|
|
259
|
+
// it off. Give the watchdog's KILL a bounded time to land.
|
|
260
|
+
" cursor_shell_poll=0", ` while [ "$cursor_shell_poll" -lt ${Math.ceil(TIMEOUT_SETTLE_MS / POLL_INTERVAL_MS)} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", " done", "fi", 'cat "$cursor_shell_log"', "if cursor_shell_timed_out; then",
|
|
261
|
+
// Stop the watchdog once the payload has exited, so its delayed KILL cannot
|
|
262
|
+
// reach a reused PID; while the payload lives, that KILL is still due.
|
|
263
|
+
' if [ -n "$cursor_shell_watchdog_pid" ] && ! kill -0 "$cursor_shell_pid" 2>/dev/null; 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",
|
|
264
|
+
// Only an exited payload reaches here, so this wait cannot block.
|
|
265
|
+
' wait "$cursor_shell_pid" 2>/dev/null', " cursor_shell_code=$?", ' 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');
|
|
135
266
|
return lines.join("\n");
|
|
136
267
|
}
|
|
137
268
|
/**
|
|
@@ -145,22 +276,26 @@ export function buildSoftBackgroundCommand(policy) {
|
|
|
145
276
|
* Residual: the detached child is not reaped by this provider after OpenCode
|
|
146
277
|
* completes the tool call; cleanup is left to the user / OS.
|
|
147
278
|
*/
|
|
148
|
-
export function buildBackgroundShellCommand(command) {
|
|
279
|
+
export function buildBackgroundShellCommand(command, shellPath) {
|
|
149
280
|
return [
|
|
281
|
+
`cursor_payload_shell=${shellQuote(shellPath ?? resolveCursorShellPath())}`,
|
|
282
|
+
'[ -z "$CURSOR_SHELL_EXECUTABLE" ] || cursor_payload_shell="$CURSOR_SHELL_EXECUTABLE"',
|
|
150
283
|
'bg_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-bg.XXXXXX")" || exit 1',
|
|
151
|
-
`nohup
|
|
284
|
+
`nohup "$cursor_payload_shell" -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
|
|
152
285
|
"bg_pid=$!",
|
|
153
286
|
`printf '${BACKGROUND_SHELL_MARKER}%s:%s\\n' "$bg_pid" "$bg_log"`,
|
|
154
287
|
].join("\n");
|
|
155
288
|
}
|
|
156
289
|
function wrapperBodyForPolicy(policy) {
|
|
290
|
+
if (policy.managedCommand)
|
|
291
|
+
return policy.managedCommand;
|
|
157
292
|
if (policy.backgroundSpawn)
|
|
158
|
-
return buildBackgroundShellCommand(policy.command);
|
|
293
|
+
return buildBackgroundShellCommand(policy.command, policy.shellPath);
|
|
159
294
|
if (policy.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND)
|
|
160
295
|
return buildSoftBackgroundCommand(policy);
|
|
161
296
|
return undefined;
|
|
162
297
|
}
|
|
163
|
-
function writeShellEnvInjector(wrapperBody) {
|
|
298
|
+
function writeShellEnvInjector(wrapperBody, command, shellPath) {
|
|
164
299
|
const dir = mkdtempSync(join(tmpdir(), "cursor-opencode-wrap-"));
|
|
165
300
|
const wrapperPath = join(dir, "wrapper.sh");
|
|
166
301
|
const bashEnvPath = join(dir, "bashenv.sh");
|
|
@@ -169,6 +304,12 @@ function writeShellEnvInjector(wrapperBody) {
|
|
|
169
304
|
// Sourced by bash (BASH_ENV) or zsh (.zshenv via ZDOTDIR). `exec`
|
|
170
305
|
// replaces the host shell before OpenCode's `-c <original>` body runs.
|
|
171
306
|
const injector = [
|
|
307
|
+
// The classic shell.env hook follows permission approval. If a later
|
|
308
|
+
// plugin changed that command, never run the earlier policy snapshot.
|
|
309
|
+
`if [ "\${BASH_EXECUTION_STRING:-\${ZSH_EXECUTION_STRING:-}}" != ${shellQuote(command)} ]; then`,
|
|
310
|
+
" unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
|
|
311
|
+
" return 0",
|
|
312
|
+
"fi",
|
|
172
313
|
"unset BASH_ENV ZDOTDIR ENV CURSOR_OPENCODE_WRAP_ACTIVE",
|
|
173
314
|
`exec /bin/sh ${shellQuote(wrapperPath)}`,
|
|
174
315
|
"",
|
|
@@ -180,17 +321,16 @@ function writeShellEnvInjector(wrapperBody) {
|
|
|
180
321
|
env: {
|
|
181
322
|
BASH_ENV: bashEnvPath,
|
|
182
323
|
ZDOTDIR: dir,
|
|
183
|
-
|
|
184
|
-
cleanup: () => {
|
|
185
|
-
try {
|
|
186
|
-
rmSync(dir, { recursive: true, force: true });
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
// best-effort temp cleanup
|
|
190
|
-
}
|
|
324
|
+
CURSOR_SHELL_EXECUTABLE: shellPath ?? resolveCursorShellPath(),
|
|
191
325
|
},
|
|
192
326
|
};
|
|
193
327
|
}
|
|
328
|
+
function cleanupShellEnvWrap(wrap) {
|
|
329
|
+
try {
|
|
330
|
+
rmSync(pathDirectory(wrap.wrapperPath), { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
catch { /* best effort */ }
|
|
333
|
+
}
|
|
194
334
|
function ensureShellEnvWrap(toolCallId, policy) {
|
|
195
335
|
const existing = activeEnvWraps.get(toolCallId);
|
|
196
336
|
if (existing)
|
|
@@ -198,25 +338,26 @@ function ensureShellEnvWrap(toolCallId, policy) {
|
|
|
198
338
|
const wrapperBody = wrapperBodyForPolicy(policy);
|
|
199
339
|
if (!wrapperBody)
|
|
200
340
|
return undefined;
|
|
201
|
-
const wrap = writeShellEnvInjector(wrapperBody);
|
|
202
|
-
remember(activeEnvWraps, toolCallId, wrap,
|
|
341
|
+
const wrap = writeShellEnvInjector(wrapperBody, policy.command, policy.shellPath);
|
|
342
|
+
remember(activeEnvWraps, toolCallId, wrap, cleanupShellEnvWrap);
|
|
203
343
|
return wrap;
|
|
204
344
|
}
|
|
205
345
|
/**
|
|
206
346
|
* Prepare OpenCode Bash args before execution when Cursor requested wrapping.
|
|
207
347
|
*
|
|
208
348
|
* bash/zsh source the shell.env injector, so the original command remains in
|
|
209
|
-
* OpenCode's permission/UI state. sh/dash ignore those startup variables;
|
|
210
|
-
*
|
|
349
|
+
* OpenCode's permission/UI state. sh/dash ignore those startup variables;
|
|
350
|
+
* managed jobs expose the complete launcher and payload for host approval.
|
|
211
351
|
*
|
|
212
352
|
* background_shell_spawn may already contain the inline non-plugin fallback.
|
|
213
353
|
* The classic hook replaces it with the original command (bash/zsh) or the
|
|
214
|
-
*
|
|
354
|
+
* inline managed launcher (sh/dash), avoiding duplicate execution.
|
|
215
355
|
*/
|
|
216
356
|
export function prepareCursorShellArgs(toolCallId, args, options = {}) {
|
|
217
357
|
const policy = policies.get(toolCallId);
|
|
218
358
|
if (!policy)
|
|
219
359
|
return;
|
|
360
|
+
policy.shellPath = options.shellPath ?? policy.shellPath;
|
|
220
361
|
if (!policy.backgroundSpawn && policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
|
|
221
362
|
return;
|
|
222
363
|
pendingEnvWraps.add(toolCallId);
|
|
@@ -225,10 +366,15 @@ export function prepareCursorShellArgs(toolCallId, args, options = {}) {
|
|
|
225
366
|
// timeout is only an outer safety net and must not win the race.
|
|
226
367
|
args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
|
|
227
368
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
369
|
+
if (options.deferToShellHook) {
|
|
370
|
+
// OpenCode 2 selects the actual shell later. Keep the approved command
|
|
371
|
+
// intact; its create hook installs an executable adapter, not a command.
|
|
372
|
+
args.command = policy.command;
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const shellKind = resolveCursorShellKind(policy.shellPath);
|
|
376
|
+
// A host without the create hook cannot install an executable adapter.
|
|
377
|
+
// Managed jobs then disclose their inline launcher for ordinary approval.
|
|
232
378
|
const envInjectable = !options.preferWrapperCommand && (shellKind === "bash" || shellKind === "zsh");
|
|
233
379
|
if (process.platform === "win32" || envInjectable) {
|
|
234
380
|
// Native Windows PowerShell/cmd wrapping remains unsupported; do not emit
|
|
@@ -238,7 +384,7 @@ export function prepareCursorShellArgs(toolCallId, args, options = {}) {
|
|
|
238
384
|
}
|
|
239
385
|
const wrap = ensureShellEnvWrap(toolCallId, policy);
|
|
240
386
|
if (wrap)
|
|
241
|
-
args.command = `exec /bin/sh ${shellQuote(wrap.wrapperPath)}`;
|
|
387
|
+
args.command = policy.managedCommand ?? `exec /bin/sh ${shellQuote(wrap.wrapperPath)}`;
|
|
242
388
|
}
|
|
243
389
|
/** Restore the model-facing command in OpenCode's completed tool title. */
|
|
244
390
|
export function cursorShellOriginalCommand(toolCallId) {
|
|
@@ -246,12 +392,14 @@ export function cursorShellOriginalCommand(toolCallId) {
|
|
|
246
392
|
}
|
|
247
393
|
/** Drop injector temp files for a finished/abandoned Cursor shell call. */
|
|
248
394
|
export function releaseCursorShellEnv(toolCallId) {
|
|
395
|
+
disarmCursorShellCreate(toolCallId);
|
|
249
396
|
pendingEnvWraps.delete(toolCallId);
|
|
397
|
+
releaseUnstartedTerminal(policies.get(toolCallId)?.managedJobDirectory);
|
|
250
398
|
const active = activeEnvWraps.get(toolCallId);
|
|
251
399
|
if (!active)
|
|
252
400
|
return;
|
|
253
401
|
activeEnvWraps.delete(toolCallId);
|
|
254
|
-
active
|
|
402
|
+
cleanupShellEnvWrap(active);
|
|
255
403
|
}
|
|
256
404
|
/**
|
|
257
405
|
* Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
|
|
@@ -269,36 +417,82 @@ export function cursorShellEnvForCall(toolCallId) {
|
|
|
269
417
|
pendingEnvWraps.delete(toolCallId);
|
|
270
418
|
return wrap.env;
|
|
271
419
|
}
|
|
420
|
+
function canonicalDirectory(directory) {
|
|
421
|
+
try {
|
|
422
|
+
return realpathSync(directory);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return resolve(directory);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
272
428
|
/**
|
|
273
|
-
* OpenCode 2.0
|
|
274
|
-
*
|
|
275
|
-
*
|
|
429
|
+
* OpenCode 2.0.27 calls create.before before its permission scanner, then
|
|
430
|
+
* spawns the selected executable with [-c, command]. Preserve that command
|
|
431
|
+
* and the shell basename the scanner uses. The adapter runs only after host
|
|
432
|
+
* approval, and uses the host's selected executable for the actual payload.
|
|
433
|
+
* Sources: upstream packages/core/src/shell.ts:274-305 and
|
|
434
|
+
* packages/core/src/tool/plugin/shell.ts:125-145 at v2.0.27.
|
|
276
435
|
*/
|
|
277
|
-
export function
|
|
278
|
-
if (
|
|
279
|
-
return
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
436
|
+
export function prepareCursorShellCreate(event) {
|
|
437
|
+
if (process.platform === "win32")
|
|
438
|
+
return;
|
|
439
|
+
// The lane holder whose call now carries this command owns the create.
|
|
440
|
+
// Release it even when its adapter no longer matches (a later hook changed
|
|
441
|
+
// the command or cwd): the create runs unwrapped and must not hold back
|
|
442
|
+
// identical commands. Holders that hooks gave one command cannot be told
|
|
443
|
+
// apart, so none of them is wrapped and each keeps its lane.
|
|
444
|
+
const owners = [...createGate.values()].map((lane) => lane.lockedBy)
|
|
445
|
+
.filter((id) => callCommand(armedCalls.get(id)) === event.command);
|
|
446
|
+
if (owners.length > 1)
|
|
447
|
+
return;
|
|
448
|
+
const owner = owners[0];
|
|
449
|
+
if (owner !== undefined)
|
|
450
|
+
disarmCursorShellCreate(owner);
|
|
451
|
+
if (owner !== undefined && !pendingEnvWraps.has(owner))
|
|
452
|
+
return;
|
|
289
453
|
for (const [id, policy] of policies) {
|
|
290
|
-
if (!pendingEnvWraps.has(id)
|
|
454
|
+
if (!pendingEnvWraps.has(id) || policy.command !== event.command
|
|
455
|
+
|| (policy.workingDirectory && canonicalDirectory(policy.workingDirectory) !== canonicalDirectory(event.cwd)))
|
|
456
|
+
continue;
|
|
457
|
+
// Only its owner's create may take an armed call's wrap. Without an arm
|
|
458
|
+
// (unit tests / non-plugin paths), keep single-match selection.
|
|
459
|
+
if (owner !== undefined ? id !== owner : armedCalls.has(id))
|
|
291
460
|
continue;
|
|
292
|
-
|
|
293
|
-
|
|
461
|
+
const wrap = ensureShellEnvWrap(id, policy);
|
|
462
|
+
if (!wrap)
|
|
463
|
+
break;
|
|
464
|
+
const directory = join(resolve(wrap.wrapperPath, ".."), "shell");
|
|
465
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
466
|
+
const executable = join(directory, basename(event.shell));
|
|
467
|
+
const script = [
|
|
468
|
+
"#!/bin/sh",
|
|
469
|
+
`if [ "$1" = -c ] && [ "$2" = ${shellQuote(event.command)} ]; then`,
|
|
470
|
+
` export CURSOR_SHELL_EXECUTABLE=${shellQuote(event.shell)}`,
|
|
471
|
+
` exec /bin/sh ${shellQuote(wrap.wrapperPath)}`,
|
|
472
|
+
"fi",
|
|
473
|
+
// A later host hook changed the command: execute exactly what the host
|
|
474
|
+
// approved instead of the provider's earlier command snapshot.
|
|
475
|
+
`exec ${shellQuote(event.shell)} "$@"`,
|
|
476
|
+
"",
|
|
477
|
+
].join("\n");
|
|
478
|
+
writeFileSync(executable, script, { mode: 0o700 });
|
|
479
|
+
event.shell = executable;
|
|
480
|
+
pendingEnvWraps.delete(id);
|
|
481
|
+
break;
|
|
294
482
|
}
|
|
295
|
-
|
|
483
|
+
// The owner's create ran, wrapped or not; no later create may take its wrap.
|
|
484
|
+
if (owner !== undefined)
|
|
485
|
+
pendingEnvWraps.delete(owner);
|
|
296
486
|
}
|
|
297
487
|
function withoutMarker(output, index) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return clean;
|
|
488
|
+
// OpenCode appends two LFs before its native timeout envelope. Preserve the
|
|
489
|
+
// payload, including whitespace-only output, and strip only its placeholder.
|
|
490
|
+
const clean = output.slice(0, index).replace(/\n\n$/, "");
|
|
491
|
+
return clean === "(no output)" ? "" : clean;
|
|
492
|
+
}
|
|
493
|
+
/** Our launchers append exactly one LF before their sentinel, even after CR. */
|
|
494
|
+
function withoutPrivateMarker(output, index) {
|
|
495
|
+
return output.slice(0, index).replace(/\n$/, "");
|
|
302
496
|
}
|
|
303
497
|
/**
|
|
304
498
|
* OpenCode only stores/renders text (`output` / `metadata.output`). Private
|
|
@@ -309,9 +503,14 @@ function withoutMarker(output, index) {
|
|
|
309
503
|
function formatShellOutcomeDisplay(clean, outcome) {
|
|
310
504
|
let notice;
|
|
311
505
|
if (outcome.kind === "backgrounded") {
|
|
506
|
+
// A managed terminal has its own shell id and an output file to poll.
|
|
507
|
+
const identity = outcome.outputFile
|
|
508
|
+
? `shell ${outcome.shellId}, pid ${outcome.pid}`
|
|
509
|
+
: `pid ${outcome.pid}`;
|
|
510
|
+
const output = outcome.outputFile ? ` Output: ${outcome.outputFile}` : "";
|
|
312
511
|
notice = outcome.msToWait > 0
|
|
313
|
-
? `Still running in the background (
|
|
314
|
-
: `Started in the background (
|
|
512
|
+
? `Still running in the background (${identity}) after ${outcome.msToWait}ms.${output}`
|
|
513
|
+
: `Started in the background (${identity}).${output}`;
|
|
315
514
|
}
|
|
316
515
|
else if (outcome.kind === "timeout") {
|
|
317
516
|
notice = `Timed out after ${outcome.timeoutMs}ms.`;
|
|
@@ -346,69 +545,96 @@ function lastPrivateMarker(output, marker, valuePattern) {
|
|
|
346
545
|
}
|
|
347
546
|
return last;
|
|
348
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Turn a stored timeout display back into OpenCode's own timeout block, so a
|
|
550
|
+
* replayed result parses as the timeout it was after its saved outcome is gone.
|
|
551
|
+
*/
|
|
552
|
+
export function restoreOpenCodeTimeout(display, timeoutMs) {
|
|
553
|
+
const notice = `Timed out after ${timeoutMs}ms.\n`;
|
|
554
|
+
const progress = display.endsWith(notice) ? display.slice(0, -notice.length).replace(/\n$/, "") : display;
|
|
555
|
+
return `${progress || "(no output)"}\n\n<shell_metadata>\nshell tool terminated command after exceeding timeout ${timeoutMs} ms.\n</shell_metadata>`;
|
|
556
|
+
}
|
|
557
|
+
/** The timeout recorded for a call whose result has not been consumed yet. */
|
|
558
|
+
export function cursorShellTimeoutMs(toolCallId) {
|
|
559
|
+
const outcome = outcomes.get(toolCallId);
|
|
560
|
+
return outcome?.kind === "timeout" ? outcome.timeoutMs : undefined;
|
|
561
|
+
}
|
|
349
562
|
function parseOpenCodeTimeout(output) {
|
|
350
563
|
const closeTag = "</shell_metadata>";
|
|
351
564
|
const closeAt = output.lastIndexOf(closeTag);
|
|
352
565
|
if (closeAt === -1 || output.slice(closeAt + closeTag.length).trim() !== "")
|
|
353
566
|
return undefined;
|
|
354
|
-
const
|
|
355
|
-
|
|
567
|
+
const openAt = output.lastIndexOf("<shell_metadata>", closeAt);
|
|
568
|
+
if (openAt < 0)
|
|
569
|
+
return undefined;
|
|
570
|
+
const header = /^<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\./;
|
|
571
|
+
const match = header.exec(output.slice(openAt, closeAt));
|
|
356
572
|
if (!match || match.index === undefined)
|
|
357
573
|
return undefined;
|
|
358
|
-
return { output: withoutMarker(output,
|
|
574
|
+
return { output: withoutMarker(output, openAt), timeoutMs: Number(match[1]) };
|
|
359
575
|
}
|
|
360
576
|
function parseSoftBackgroundOutcome(output, policy) {
|
|
361
|
-
const background = lastPrivateMarker(output, BACKGROUND_MARKER, "(\\d+):([^\\r\\n]+)");
|
|
362
|
-
|
|
363
|
-
|
|
577
|
+
const background = lastPrivateMarker(output, BACKGROUND_MARKER, "(\\d+):(?:(\\d+):)?([^\\r\\n]+)");
|
|
578
|
+
const timeout = lastPrivateMarker(output, TIMEOUT_MARKER, "(\\d+)");
|
|
579
|
+
const exit = lastPrivateMarker(output, EXIT_MARKER, "(-?\\d+)");
|
|
580
|
+
// The launcher appends the authoritative outcome after the command's
|
|
581
|
+
// stdout. An earlier printed sentinel must not override that final marker.
|
|
582
|
+
const finalIndex = Math.max(background?.index ?? -1, timeout?.index ?? -1, exit?.index ?? -1);
|
|
583
|
+
if (background && background.index === finalIndex) {
|
|
584
|
+
const shellId = Number(background.values[0]);
|
|
585
|
+
const pid = Number(background.values[1] || shellId);
|
|
364
586
|
if (Number.isSafeInteger(pid) && pid > 0 && pid <= 0xffff_ffff) {
|
|
365
587
|
return {
|
|
366
|
-
output:
|
|
588
|
+
output: withoutPrivateMarker(output, background.index),
|
|
367
589
|
outcome: {
|
|
368
590
|
kind: "backgrounded",
|
|
369
|
-
shellId
|
|
591
|
+
shellId,
|
|
370
592
|
pid,
|
|
371
593
|
command: policy?.command ?? "",
|
|
372
594
|
workingDirectory: policy?.workingDirectory ?? "",
|
|
373
595
|
msToWait: policy?.timeoutMs ?? 0,
|
|
374
596
|
reason: 1,
|
|
597
|
+
...(background.values[1] && background.values[2] ? { outputFile: background.values[2] } : {}),
|
|
375
598
|
},
|
|
376
599
|
};
|
|
377
600
|
}
|
|
378
601
|
}
|
|
379
|
-
|
|
380
|
-
if (timeout) {
|
|
602
|
+
if (timeout && timeout.index === finalIndex) {
|
|
381
603
|
return {
|
|
382
|
-
output:
|
|
604
|
+
output: withoutPrivateMarker(output, timeout.index),
|
|
383
605
|
outcome: { kind: "timeout", timeoutMs: Number(timeout.values[0]) },
|
|
384
606
|
};
|
|
385
607
|
}
|
|
386
|
-
|
|
387
|
-
if (exit) {
|
|
608
|
+
if (exit && exit.index === finalIndex) {
|
|
388
609
|
return {
|
|
389
|
-
output:
|
|
610
|
+
output: withoutPrivateMarker(output, exit.index),
|
|
390
611
|
outcome: { kind: "exit", code: Number(exit.values[0]) },
|
|
391
612
|
};
|
|
392
613
|
}
|
|
393
614
|
return undefined;
|
|
394
615
|
}
|
|
395
616
|
function parseBackgroundSpawnOutcome(output, policy) {
|
|
396
|
-
const match = lastPrivateMarker(output, BACKGROUND_SHELL_MARKER, "(\\d+):([^\\r\\n]+)");
|
|
617
|
+
const match = lastPrivateMarker(output, BACKGROUND_SHELL_MARKER, "(\\d+):(?:(\\d+):)?([^\\r\\n]+)");
|
|
397
618
|
if (!match)
|
|
398
619
|
return undefined;
|
|
399
|
-
const
|
|
620
|
+
const finished = lastPrivateMarker(output, "__CURSOR_SHELL_(?:BACKGROUND|TIMEOUT|EXIT)__", "[^\\r\\n]*");
|
|
621
|
+
if (finished && finished.index > match.index)
|
|
622
|
+
return undefined;
|
|
623
|
+
const shellId = Number(match.values[0]);
|
|
624
|
+
const pid = Number(match.values[1] || shellId);
|
|
400
625
|
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff)
|
|
401
626
|
return undefined;
|
|
402
627
|
return {
|
|
403
|
-
output:
|
|
628
|
+
output: withoutPrivateMarker(output, match.index),
|
|
404
629
|
outcome: {
|
|
405
630
|
kind: "backgrounded",
|
|
406
|
-
shellId
|
|
631
|
+
shellId,
|
|
407
632
|
pid,
|
|
408
633
|
command: policy?.command ?? "",
|
|
409
634
|
workingDirectory: policy?.workingDirectory ?? "",
|
|
410
635
|
msToWait: 0,
|
|
411
636
|
reason: 1,
|
|
637
|
+
...(match.values[1] && match.values[2] ? { outputFile: match.values[2] } : {}),
|
|
412
638
|
},
|
|
413
639
|
};
|
|
414
640
|
}
|
|
@@ -420,20 +646,26 @@ function parseBackgroundSpawnOutcome(output, policy) {
|
|
|
420
646
|
* a string. Non-string input is returned unchanged so the 2.0 after-hook can
|
|
421
647
|
* pass structured output through safely.
|
|
422
648
|
*/
|
|
423
|
-
export function sanitizeCursorShellDisplayOutput(output, policy) {
|
|
649
|
+
export function sanitizeCursorShellDisplayOutput(output, policy, capturedOutcome) {
|
|
424
650
|
if (typeof output !== "string")
|
|
425
651
|
return output;
|
|
426
652
|
if (policy?.backgroundSpawn) {
|
|
427
653
|
const spawn = parseBackgroundSpawnOutcome(output, policy);
|
|
428
654
|
if (spawn)
|
|
429
655
|
return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
|
|
656
|
+
const failed = parseSoftBackgroundOutcome(output, policy);
|
|
657
|
+
if (failed)
|
|
658
|
+
return formatShellOutcomeDisplay(failed.output, failed.outcome);
|
|
430
659
|
}
|
|
431
660
|
if (policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND) {
|
|
432
661
|
const wrapper = parseSoftBackgroundOutcome(output, policy);
|
|
433
662
|
if (wrapper)
|
|
434
663
|
return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
|
|
435
664
|
}
|
|
436
|
-
|
|
665
|
+
// Secondary display strings share the native outcome already captured from
|
|
666
|
+
// the primary output. A successful command may print this exact envelope.
|
|
667
|
+
const timeout = !capturedOutcome || capturedOutcome.kind === "timeout"
|
|
668
|
+
? parseOpenCodeTimeout(output) : undefined;
|
|
437
669
|
if (timeout) {
|
|
438
670
|
return formatShellOutcomeDisplay(timeout.output, {
|
|
439
671
|
kind: "timeout",
|
|
@@ -448,7 +680,7 @@ export function sanitizeRegisteredCursorShellOutput(toolCallId, output) {
|
|
|
448
680
|
return output;
|
|
449
681
|
if (typeof toolCallId !== "string" || !toolCallId)
|
|
450
682
|
return output;
|
|
451
|
-
return sanitizeCursorShellDisplayOutput(output, policies.get(toolCallId));
|
|
683
|
+
return sanitizeCursorShellDisplayOutput(output, policies.get(toolCallId), outcomes.get(toolCallId));
|
|
452
684
|
}
|
|
453
685
|
/**
|
|
454
686
|
* Capture Bash completion in the classic plugin's after hook. Returns the
|
|
@@ -463,12 +695,26 @@ export function captureCursorShellResult(toolCallId, output, metadata) {
|
|
|
463
695
|
if (typeof toolCallId !== "string" || !toolCallId.startsWith("cursor_"))
|
|
464
696
|
return output;
|
|
465
697
|
const policy = policies.get(toolCallId);
|
|
698
|
+
// Classic OpenCode returns exit:null only for its timeout/abort race. That
|
|
699
|
+
// native outcome wins over any sentinel printed before the host killed it.
|
|
700
|
+
// https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/src/tool/shell.ts#L542-L589
|
|
701
|
+
if (metadata?.exit === null) {
|
|
702
|
+
const timeout = parseOpenCodeTimeout(output);
|
|
703
|
+
const outcome = timeout ? { kind: "timeout", timeoutMs: timeout.timeoutMs } : { kind: "aborted" };
|
|
704
|
+
remember(outcomes, toolCallId, outcome);
|
|
705
|
+
return timeout ? formatShellOutcomeDisplay(timeout.output, outcome) : output;
|
|
706
|
+
}
|
|
466
707
|
if (policy?.backgroundSpawn) {
|
|
467
708
|
const spawn = parseBackgroundSpawnOutcome(output, policy);
|
|
468
709
|
if (spawn) {
|
|
469
710
|
remember(outcomes, toolCallId, spawn.outcome);
|
|
470
711
|
return formatShellOutcomeDisplay(spawn.output, spawn.outcome);
|
|
471
712
|
}
|
|
713
|
+
const finished = parseSoftBackgroundOutcome(output, policy);
|
|
714
|
+
if (finished) {
|
|
715
|
+
remember(outcomes, toolCallId, finished.outcome);
|
|
716
|
+
return formatShellOutcomeDisplay(finished.output, finished.outcome);
|
|
717
|
+
}
|
|
472
718
|
}
|
|
473
719
|
// Private wrapper sentinels are meaningful only for calls we transformed.
|
|
474
720
|
// A normal foreground command is allowed to print the same text verbatim.
|
|
@@ -479,7 +725,8 @@ export function captureCursorShellResult(toolCallId, output, metadata) {
|
|
|
479
725
|
remember(outcomes, toolCallId, wrapper.outcome);
|
|
480
726
|
return formatShellOutcomeDisplay(wrapper.output, wrapper.outcome);
|
|
481
727
|
}
|
|
482
|
-
|
|
728
|
+
// When the host supplied an exit code, successful stdout is not metadata.
|
|
729
|
+
const timeout = metadata?.exit === undefined ? parseOpenCodeTimeout(output) : undefined;
|
|
483
730
|
if (timeout) {
|
|
484
731
|
const outcome = { kind: "timeout", timeoutMs: timeout.timeoutMs };
|
|
485
732
|
remember(outcomes, toolCallId, outcome);
|
|
@@ -490,27 +737,50 @@ export function captureCursorShellResult(toolCallId, output, metadata) {
|
|
|
490
737
|
remember(outcomes, toolCallId, { kind: "exit", code: exitCode });
|
|
491
738
|
return output;
|
|
492
739
|
}
|
|
740
|
+
/** Record the host's authoritative error status, even if its SDK drops that flag. */
|
|
741
|
+
export function captureCursorShellFailure(toolCallId, error) {
|
|
742
|
+
if (!policies.has(toolCallId))
|
|
743
|
+
return;
|
|
744
|
+
const message = (error instanceof Error ? error.message : String(error ?? "")) || "Shell execution failed";
|
|
745
|
+
remember(failures, toolCallId, message);
|
|
746
|
+
}
|
|
493
747
|
/** Consume the structured result, with an inline fallback when no plugin hook ran. */
|
|
494
748
|
export function consumeCursorShellResult(toolCallId, output) {
|
|
495
749
|
if (typeof toolCallId !== "string" || !toolCallId) {
|
|
496
750
|
return { output };
|
|
497
751
|
}
|
|
498
752
|
let clean = output;
|
|
499
|
-
|
|
753
|
+
const saved = outcomes.get(toolCallId);
|
|
754
|
+
if (!saved)
|
|
500
755
|
clean = captureCursorShellResult(toolCallId, output);
|
|
756
|
+
// A replayed timeout carries OpenCode's block; the saved outcome already says it.
|
|
757
|
+
const restored = saved?.kind === "timeout" ? parseOpenCodeTimeout(output) : undefined;
|
|
758
|
+
if (saved && restored)
|
|
759
|
+
clean = formatShellOutcomeDisplay(restored.output, saved);
|
|
501
760
|
const outcome = outcomes.get(toolCallId);
|
|
761
|
+
const error = failures.get(toolCallId);
|
|
762
|
+
failures.delete(toolCallId);
|
|
502
763
|
outcomes.delete(toolCallId);
|
|
503
|
-
policies.delete(toolCallId);
|
|
504
764
|
releaseCursorShellEnv(toolCallId);
|
|
505
|
-
|
|
765
|
+
policies.delete(toolCallId);
|
|
766
|
+
return { output: clean, outcome, ...(error ? { error } : {}) };
|
|
506
767
|
}
|
|
507
768
|
/** Test/process cleanup. */
|
|
508
769
|
export function resetCursorShellCalls() {
|
|
770
|
+
const waiters = [...createGate.values()].flatMap((lane) => lane.waiters);
|
|
771
|
+
createGate.clear();
|
|
772
|
+
armedCalls.clear();
|
|
773
|
+
for (const waiter of waiters)
|
|
774
|
+
waiter.reject(new Error("cursor shell create gate reset"));
|
|
509
775
|
for (const wrap of activeEnvWraps.values())
|
|
510
|
-
wrap
|
|
776
|
+
cleanupShellEnvWrap(wrap);
|
|
777
|
+
for (const policy of policies.values())
|
|
778
|
+
releaseUnstartedTerminal(policy.managedJobDirectory);
|
|
511
779
|
activeEnvWraps.clear();
|
|
512
780
|
pendingEnvWraps.clear();
|
|
513
781
|
policies.clear();
|
|
514
782
|
outcomes.clear();
|
|
515
|
-
|
|
783
|
+
failures.clear();
|
|
784
|
+
shellConfiguration.defaultShell = undefined;
|
|
785
|
+
configuredShells.clear();
|
|
516
786
|
}
|