cursor-opencode-provider 0.8.1 → 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.
Files changed (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. package/dist/protocol/thinking.js +0 -17
@@ -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
- const policies = new Map();
16
- const outcomes = new Map();
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
- let configuredShell;
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
- configuredShell = shell?.trim() || undefined;
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 = configuredShell ?? process.env.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
- remember(policies, toolCallId, policy);
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 sh -c ${shellQuote(policy.command)} >"$cursor_shell_log" 2>&1 </dev/null &`,
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", 'if [ -n "$cursor_shell_watchdog_pid" ]; then disown "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', 'disown "$cursor_shell_pid" 2>/dev/null || true', "cursor_shell_poll=0", `while [ "$cursor_shell_poll" -lt ${polls} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", "done", 'if kill -0 "$cursor_shell_pid" 2>/dev/null; then', ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", "fi", 'wait "$cursor_shell_pid" 2>/dev/null', "cursor_shell_code=$?", 'cat "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; then',
132
- // Reap the watchdog before printing the private marker so any residual
133
- // shell diagnostics cannot trail the sentinel.
134
- ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; wait "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${TIMEOUT_MARKER}%s\n' ${policy.hardTimeoutMs ?? policy.timeoutMs}`, "else", ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; wait "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${EXIT_MARKER}%s\n' "$cursor_shell_code"`, "fi", 'rm -f -- "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ]; then rm -f -- "$cursor_shell_status"; fi');
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 sh -c ${shellQuote(command)} >"$bg_log" 2>&1 </dev/null &`,
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, (evicted) => evicted.cleanup());
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; for
210
- * them, use a short `exec wrapper.sh` command that contains no user payload.
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
- * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
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
- const shellKind = resolveCursorShellKind();
229
- // OpenCode 2.0 has no `shell.env` hook, so bash/zsh cannot be wrapped by
230
- // sourcing an injector. Callers there opt into the wrapper-file command —
231
- // the same mechanism sh/dash already use — which needs no env injection.
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.cleanup();
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 `shell.create.before` has no tool-call id. Correlate the pending
274
- * wrap by command + working directory when possible, then fall back to the
275
- * original command for hosts that omit the directory.
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 cursorShellEnvForCommand(command, workingDirectory) {
278
- if (typeof command !== "string" || !command)
279
- return undefined;
280
- if (workingDirectory) {
281
- for (const [id, policy] of policies) {
282
- if (!pendingEnvWraps.has(id))
283
- continue;
284
- if (policy.command === command && policy.workingDirectory === workingDirectory) {
285
- return cursorShellEnvForCall(id);
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
- if (policy.command === command)
293
- return cursorShellEnvForCall(id);
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
- return undefined;
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
- let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
299
- if (clean.trim() === "" || clean.trim() === "(no output)")
300
- clean = "";
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 (pid ${outcome.pid}) after ${outcome.msToWait}ms.`
314
- : `Started in the background (pid ${outcome.pid}).`;
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 header = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\./;
355
- const match = header.exec(output.slice(0, closeAt));
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, match.index), timeoutMs: Number(match[1]) };
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
- if (background) {
363
- const pid = Number(background.values[0]);
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: withoutMarker(output, background.index),
588
+ output: withoutPrivateMarker(output, background.index),
367
589
  outcome: {
368
590
  kind: "backgrounded",
369
- shellId: pid,
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
- const timeout = lastPrivateMarker(output, TIMEOUT_MARKER, "(\\d+)");
380
- if (timeout) {
602
+ if (timeout && timeout.index === finalIndex) {
381
603
  return {
382
- output: withoutMarker(output, timeout.index),
604
+ output: withoutPrivateMarker(output, timeout.index),
383
605
  outcome: { kind: "timeout", timeoutMs: Number(timeout.values[0]) },
384
606
  };
385
607
  }
386
- const exit = lastPrivateMarker(output, EXIT_MARKER, "(-?\\d+)");
387
- if (exit) {
608
+ if (exit && exit.index === finalIndex) {
388
609
  return {
389
- output: withoutMarker(output, exit.index),
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 pid = Number(match.values[0]);
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: withoutMarker(output, match.index),
628
+ output: withoutPrivateMarker(output, match.index),
404
629
  outcome: {
405
630
  kind: "backgrounded",
406
- shellId: pid,
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
- const timeout = parseOpenCodeTimeout(output);
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
- const timeout = parseOpenCodeTimeout(output);
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
- if (!outcomes.has(toolCallId))
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
- return { output: clean, outcome };
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.cleanup();
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
- configuredShell = undefined;
783
+ failures.clear();
784
+ shellConfiguration.defaultShell = undefined;
785
+ configuredShells.clear();
516
786
  }