cursor-opencode-provider 0.2.3 → 0.2.5

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.
@@ -0,0 +1,186 @@
1
+ /** Cursor agent.v1 TimeoutBehavior enum values. */
2
+ export const CURSOR_TIMEOUT_CANCEL = 1;
3
+ export const CURSOR_TIMEOUT_BACKGROUND = 2;
4
+ const MAX_TRACKED_SHELL_CALLS = 512;
5
+ const OPENCODE_TIMEOUT_GRACE_MS = 15_000;
6
+ const POLL_INTERVAL_MS = 100;
7
+ const BACKGROUND_MARKER = "__CURSOR_SHELL_BACKGROUND__";
8
+ const EXIT_MARKER = "__CURSOR_SHELL_EXIT__";
9
+ const TIMEOUT_MARKER = "__CURSOR_SHELL_TIMEOUT__";
10
+ const policies = new Map();
11
+ const outcomes = new Map();
12
+ function remember(map, key, value) {
13
+ map.delete(key);
14
+ map.set(key, value);
15
+ while (map.size > MAX_TRACKED_SHELL_CALLS) {
16
+ const oldest = map.keys().next().value;
17
+ if (!oldest)
18
+ break;
19
+ map.delete(oldest);
20
+ }
21
+ }
22
+ function finiteNonNegative(value) {
23
+ const n = typeof value === "number" ? value : Number(value);
24
+ if (!Number.isFinite(n) || n < 0)
25
+ return undefined;
26
+ return Math.floor(n);
27
+ }
28
+ export function shellPolicyFromMetadata(metadata) {
29
+ if (!metadata || metadata.shell_stream !== true)
30
+ return undefined;
31
+ const timeoutMs = finiteNonNegative(metadata.timeout_ms) ?? 30_000;
32
+ const timeoutBehavior = finiteNonNegative(metadata.timeout_behavior) ?? 0;
33
+ const hardTimeoutMs = finiteNonNegative(metadata.hard_timeout_ms);
34
+ return {
35
+ command: typeof metadata.command === "string" ? metadata.command : "",
36
+ workingDirectory: typeof metadata.working_directory === "string" ? metadata.working_directory : "",
37
+ timeoutMs,
38
+ timeoutBehavior,
39
+ ...(hardTimeoutMs !== undefined && hardTimeoutMs > 0 ? { hardTimeoutMs } : {}),
40
+ };
41
+ }
42
+ /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
43
+ export function registerCursorShellCall(toolCallId, metadata) {
44
+ const policy = shellPolicyFromMetadata(metadata);
45
+ if (!policy || !toolCallId.startsWith("cursor_"))
46
+ return;
47
+ remember(policies, toolCallId, policy);
48
+ }
49
+ function shellQuote(value) {
50
+ return `'${value.replace(/'/g, `'\\''`)}'`;
51
+ }
52
+ /**
53
+ * F11 / soft-background helper.
54
+ *
55
+ * Run a Cursor soft-background command for its foreground window, then leave
56
+ * it detached (`nohup`) if still alive. The sentinel is removed by the after
57
+ * hook before OpenCode stores/renders the result.
58
+ *
59
+ * This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
60
+ * foreground-only bash tool. Residual: after OpenCode returns, the child (and
61
+ * optional hard-timeout watchdog) may still be running; this provider does not
62
+ * reap leftover processes — cleanup is left to the user / OS.
63
+ */
64
+ export function buildSoftBackgroundCommand(policy) {
65
+ const polls = Math.ceil(policy.timeoutMs / POLL_INTERVAL_MS);
66
+ const hardPolls = policy.hardTimeoutMs !== undefined
67
+ ? Math.max(1, Math.ceil(policy.hardTimeoutMs / POLL_INTERVAL_MS))
68
+ : undefined;
69
+ const lines = [
70
+ 'cursor_shell_log="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-shell.XXXXXX")" || exit 1',
71
+ `nohup sh -c ${shellQuote(policy.command)} >"$cursor_shell_log" 2>&1 </dev/null &`,
72
+ "cursor_shell_pid=$!",
73
+ ];
74
+ if (hardPolls !== undefined) {
75
+ lines.push('cursor_shell_status="$(mktemp "${TMPDIR:-/tmp}/cursor-opencode-shell-status.XXXXXX")" || exit 1', `nohup sh -c 'cursor_hard_poll=0; while [ "$cursor_hard_poll" -lt "$1" ] && kill -0 "$2" 2>/dev/null; do sleep ${POLL_INTERVAL_MS / 1000}; cursor_hard_poll=$((cursor_hard_poll + 1)); done; if kill -0 "$2" 2>/dev/null; then printf timeout >"$3"; kill -TERM "$2" 2>/dev/null; sleep 3; kill -KILL "$2" 2>/dev/null; fi' cursor-shell-watchdog ${hardPolls} "$cursor_shell_pid" "$cursor_shell_status" >/dev/null 2>&1 </dev/null &`, "cursor_shell_watchdog_pid=$!");
76
+ }
77
+ else {
78
+ lines.push('cursor_shell_status=""', 'cursor_shell_watchdog_pid=""');
79
+ }
80
+ lines.push("cursor_shell_poll=0", `while [ "$cursor_shell_poll" -lt ${polls} ] && kill -0 "$cursor_shell_pid" 2>/dev/null; do`, ` sleep ${POLL_INTERVAL_MS / 1000}`, " cursor_shell_poll=$((cursor_shell_poll + 1))", "done", 'if kill -0 "$cursor_shell_pid" 2>/dev/null; then', ' cat "$cursor_shell_log"', ` printf '\n${BACKGROUND_MARKER}%s:%s\n' "$cursor_shell_pid" "$cursor_shell_log"`, " exit 0", "fi", 'wait "$cursor_shell_pid"', "cursor_shell_code=$?", 'cat "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ] && [ "$(cat "$cursor_shell_status" 2>/dev/null)" = timeout ]; then', ` printf '\n${TIMEOUT_MARKER}%s\n' ${policy.hardTimeoutMs ?? policy.timeoutMs}`, "else", ' if [ -n "$cursor_shell_watchdog_pid" ]; then kill "$cursor_shell_watchdog_pid" 2>/dev/null || true; fi', ` printf '\n${EXIT_MARKER}%s\n' "$cursor_shell_code"`, "fi", 'rm -f -- "$cursor_shell_log"', 'if [ -n "$cursor_shell_status" ]; then rm -f -- "$cursor_shell_status"; fi');
81
+ return lines.join("\n");
82
+ }
83
+ /** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
84
+ export function prepareCursorShellArgs(toolCallId, args) {
85
+ const policy = policies.get(toolCallId);
86
+ if (!policy || policy.timeoutBehavior !== CURSOR_TIMEOUT_BACKGROUND)
87
+ return;
88
+ args.command = buildSoftBackgroundCommand(policy);
89
+ // The wrapper returns just after Cursor's foreground window. OpenCode's own
90
+ // timeout is only an outer safety net and must not win the race.
91
+ args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
92
+ }
93
+ /** Restore the model-facing command in OpenCode's completed tool title. */
94
+ export function cursorShellOriginalCommand(toolCallId) {
95
+ return policies.get(toolCallId)?.command || undefined;
96
+ }
97
+ function withoutMarker(output, index) {
98
+ let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
99
+ if (clean.trim() === "" || clean.trim() === "(no output)")
100
+ clean = "";
101
+ return clean;
102
+ }
103
+ function parseOpenCodeTimeout(output) {
104
+ const re = /<shell_metadata>\r?\nshell tool terminated command after exceeding timeout (\d+) ms\.[\s\S]*?<\/shell_metadata>\s*$/;
105
+ const match = re.exec(output);
106
+ if (!match || match.index === undefined)
107
+ return undefined;
108
+ return { output: withoutMarker(output, match.index), timeoutMs: Number(match[1]) };
109
+ }
110
+ function parseWrapperOutcome(output, policy) {
111
+ const background = new RegExp(`${BACKGROUND_MARKER}(\\d+):([^\\r\\n]+)\\s*$`).exec(output);
112
+ if (background?.index !== undefined) {
113
+ const pid = Number(background[1]);
114
+ if (Number.isSafeInteger(pid) && pid > 0 && pid <= 0xffff_ffff) {
115
+ return {
116
+ output: withoutMarker(output, background.index),
117
+ outcome: {
118
+ kind: "backgrounded",
119
+ shellId: pid,
120
+ pid,
121
+ command: policy?.command ?? "",
122
+ workingDirectory: policy?.workingDirectory ?? "",
123
+ msToWait: policy?.timeoutMs ?? 0,
124
+ reason: 1,
125
+ },
126
+ };
127
+ }
128
+ }
129
+ const timeout = new RegExp(`${TIMEOUT_MARKER}(\\d+)\\s*$`).exec(output);
130
+ if (timeout?.index !== undefined) {
131
+ return {
132
+ output: withoutMarker(output, timeout.index),
133
+ outcome: { kind: "timeout", timeoutMs: Number(timeout[1]) },
134
+ };
135
+ }
136
+ const exit = new RegExp(`${EXIT_MARKER}(-?\\d+)\\s*$`).exec(output);
137
+ if (exit?.index !== undefined) {
138
+ return {
139
+ output: withoutMarker(output, exit.index),
140
+ outcome: { kind: "exit", code: Number(exit[1]) },
141
+ };
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Capture Bash completion in the classic plugin's after hook. Returns the
147
+ * sanitized output that OpenCode should store and render.
148
+ */
149
+ export function captureCursorShellResult(toolCallId, output, metadata) {
150
+ if (!toolCallId.startsWith("cursor_"))
151
+ return output;
152
+ const policy = policies.get(toolCallId);
153
+ // Private wrapper sentinels are meaningful only for calls we transformed.
154
+ // A normal foreground command is allowed to print the same text verbatim.
155
+ const wrapper = policy?.timeoutBehavior === CURSOR_TIMEOUT_BACKGROUND
156
+ ? parseWrapperOutcome(output, policy)
157
+ : undefined;
158
+ if (wrapper) {
159
+ remember(outcomes, toolCallId, wrapper.outcome);
160
+ return wrapper.output;
161
+ }
162
+ const timeout = parseOpenCodeTimeout(output);
163
+ if (timeout) {
164
+ remember(outcomes, toolCallId, { kind: "timeout", timeoutMs: timeout.timeoutMs });
165
+ return timeout.output;
166
+ }
167
+ const exitCode = finiteNonNegative(metadata?.exit);
168
+ if (exitCode !== undefined)
169
+ remember(outcomes, toolCallId, { kind: "exit", code: exitCode });
170
+ return output;
171
+ }
172
+ /** Consume the structured result, with an inline fallback when no plugin hook ran. */
173
+ export function consumeCursorShellResult(toolCallId, output) {
174
+ let clean = output;
175
+ if (!outcomes.has(toolCallId))
176
+ clean = captureCursorShellResult(toolCallId, output);
177
+ const outcome = outcomes.get(toolCallId);
178
+ outcomes.delete(toolCallId);
179
+ policies.delete(toolCallId);
180
+ return { output: clean, outcome };
181
+ }
182
+ /** Test/process cleanup. */
183
+ export function resetCursorShellCalls() {
184
+ policies.clear();
185
+ outcomes.clear();
186
+ }
@@ -1,3 +1,5 @@
1
+ import { CursorTransportError, CursorProviderError } from "../errors.js";
2
+ import http2 from "node:http2";
1
3
  export declare function buildBaseHeaders(token: string, clientVersion: string, extra?: Record<string, string>): Record<string, string>;
2
4
  export declare function unaryAvailableModels(token: string, options?: {
3
5
  apiBaseURL?: string;
@@ -30,19 +32,53 @@ export declare function fetchAgentUrl(token: string, options?: {
30
32
  telemetryEnabled?: boolean;
31
33
  }): Promise<string>;
32
34
  export type BidiStream = {
33
- write(msg: Uint8Array): void;
35
+ write(msg: Uint8Array): boolean | void;
36
+ waitForDrain?(timeoutMs: number): Promise<void>;
34
37
  end(): void;
35
38
  frames(): AsyncIterable<{
36
39
  flags: number;
37
40
  payload: Uint8Array;
38
41
  }>;
39
42
  destroy(): void;
43
+ isClosed(): boolean;
44
+ onTerminal(listener: (event: BidiTerminalEvent) => void): () => void;
40
45
  };
46
+ export type BidiTerminalEvent = {
47
+ kind: "remote-clean-close";
48
+ } | {
49
+ kind: "remote-error";
50
+ error: CursorProviderError;
51
+ } | {
52
+ kind: "local-close";
53
+ };
54
+ export declare class CursorRunInterruptedError extends CursorTransportError {
55
+ constructor(message?: string, options?: ErrorOptions);
56
+ }
57
+ export declare function cursorRunTerminationError(input: {
58
+ responseStatus: number;
59
+ responseHeaders?: Record<string, unknown>;
60
+ responseTrailers?: Record<string, unknown>;
61
+ streamError?: Error | null;
62
+ }): CursorProviderError;
63
+ export declare const HTTP2_SESSION_MAX_AGE_MS: number;
64
+ export declare function shouldReuseHttp2Session(state: {
65
+ destroyed: boolean;
66
+ closed: boolean;
67
+ }, createdAt: number, now?: number): boolean;
41
68
  /** Resolve the HTTP/2 connect origin for a Run stream (exported for tests). */
42
69
  export declare function resolveAgentOrigin(baseURL: string): string;
70
+ /** Test cleanup for local HTTP/2 fixtures; production sessions stay process-cached. */
71
+ export declare function closeCachedHttp2SessionsForTests(): void;
72
+ /** Node-runtime regression hook; production callers use getSession(). */
73
+ export declare function installSessionInvalidationForTests(origin: string, session: http2.ClientHttp2Session): void;
74
+ export declare function getSession(baseURL: string, options?: {
75
+ pingTimeoutMs?: number;
76
+ }): Promise<http2.ClientHttp2Session>;
43
77
  export declare function bidiRunStream(token: string, options: {
44
78
  signal?: AbortSignal;
45
79
  baseURL: string;
46
80
  headers?: Record<string, string>;
81
+ readIdleMs?: number;
82
+ pingTimeoutMs?: number;
47
83
  }): Promise<BidiStream>;
48
84
  export declare function makeRequestId(): string;