cursor-opencode-provider 0.2.4 → 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.
- package/README.md +12 -8
- package/SECURITY.md +42 -0
- package/dist/auth.d.ts +2 -1
- package/dist/auth.js +19 -13
- package/dist/context/rules.d.ts +4 -0
- package/dist/context/rules.js +53 -17
- package/dist/debug.d.ts +9 -0
- package/dist/debug.js +44 -4
- package/dist/language-model.d.ts +7 -3
- package/dist/language-model.js +114 -15
- package/dist/plugin.js +3 -9
- package/dist/protocol/interactions.d.ts +8 -1
- package/dist/protocol/interactions.js +15 -2
- package/dist/protocol/messages.js +4 -1
- package/dist/protocol/request.d.ts +2 -2
- package/dist/protocol/tool-call-bridge.js +11 -1
- package/dist/protocol/tools.js +131 -12
- package/dist/shell-timeout.d.ts +9 -2
- package/dist/shell-timeout.js +9 -2
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
|
|
|
47
47
|
}
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
Pin a version if you want: `"cursor-opencode-provider@0.2.
|
|
50
|
+
Pin a version if you want: `"cursor-opencode-provider@0.2.5"`.
|
|
51
51
|
|
|
52
52
|
### From a local clone
|
|
53
53
|
|
|
@@ -175,7 +175,7 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
|
|
|
175
175
|
| `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
|
|
176
176
|
| `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
|
|
177
177
|
| `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
|
|
178
|
-
| `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default
|
|
178
|
+
| `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) |
|
|
179
179
|
| `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
|
|
180
180
|
| `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
|
|
181
181
|
|
|
@@ -219,7 +219,7 @@ OpenCode
|
|
|
219
219
|
|
|
220
220
|
### Injected system guidance
|
|
221
221
|
|
|
222
|
-
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability
|
|
222
|
+
The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
|
|
223
223
|
|
|
224
224
|
If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
|
|
225
225
|
|
|
@@ -246,7 +246,11 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
|
|
|
246
246
|
| No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. |
|
|
247
247
|
| Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
|
|
248
248
|
| `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
|
|
249
|
-
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE
|
|
249
|
+
| Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
|
|
250
|
+
|
|
251
|
+
## Security
|
|
252
|
+
|
|
253
|
+
Project `instructions` may reference absolute or `~/` paths (OpenCode parity). See [SECURITY.md](./SECURITY.md) for the trust model and `OPENCODE_DISABLE_PROJECT_CONFIG`.
|
|
250
254
|
|
|
251
255
|
## Known limitations
|
|
252
256
|
|
|
@@ -256,12 +260,12 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
|
|
|
256
260
|
- **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name; known-but-unsupported and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
|
|
257
261
|
- **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
|
|
258
262
|
- **Background shells are non-interactive** — Cursor's native background-shell spawn is bridged through OpenCode's foreground-only `bash` tool by detaching the requested command, redirecting its output to `${TMPDIR:-/tmp}/cursor-opencode-bg.*`, and returning the real PID in Cursor's typed field-16 result. Shell-stream requests also preserve Cursor's foreground timeout, `timeout_behavior`, and `hard_timeout`: cancellation becomes a typed aborted exit, while a soft timeout requested with background behavior becomes a typed background handoff. OpenCode's internal `<shell_metadata>` envelope and the bridge's private markers are stripped before the result reaches the UI. Requests that require `write_shell_stdin` are rejected explicitly because OpenCode does not expose an interactive background-process lifecycle through its AI SDK tool interface.
|
|
259
|
-
- **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and permissions. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
|
|
260
|
-
- **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool-
|
|
263
|
+
- **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and permissions. Separately from display `create_plan_tool_call` → `todowrite` mirroring, interaction `create_plan_request_query` is auto-acked (CLI headless parity) with success and an empty `plan_uri`, so Cursor may treat the plan as accepted without an OpenCode UI confirm. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
|
|
264
|
+
- **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state.
|
|
261
265
|
- **Conversation bindings and compaction catalogs are bounded** — process-global per-session bindings, prior tool catalogs, and pending post-compaction rebases use a 256-session LRU bound. Evicting a conversation binding also drops its checkpoint and KV blobs.
|
|
262
|
-
- **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the
|
|
266
|
+
- **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the OpenCode prompt, retaining the live user request and trailing tool results as host observations without replaying stale historical tool output, re-advertises the same tools, and preserves compaction's no-execution behavior. A second interruption is returned as an explicit error. Recovery cannot see tokens already streamed to the UI on the interrupted attempt, so mid-generation recovery may briefly duplicate visible text/reasoning even though the rebase prompt asks the model not to repeat completed work.
|
|
263
267
|
- **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
|
|
264
268
|
|
|
265
269
|
## License
|
|
266
270
|
|
|
267
|
-
MIT
|
|
271
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Security notes
|
|
2
|
+
|
|
3
|
+
This provider mirrors OpenCode’s trust model for project configuration. That has implications for what ends up in Cursor context.
|
|
4
|
+
|
|
5
|
+
## Project `instructions` can include arbitrary local paths
|
|
6
|
+
|
|
7
|
+
OpenCode’s `opencode.json` / `opencode.jsonc` `instructions` array may list:
|
|
8
|
+
|
|
9
|
+
- relative paths / globs (e.g. `.cursor/rules/*.md`)
|
|
10
|
+
- absolute paths
|
|
11
|
+
- home-relative paths (`~/…`)
|
|
12
|
+
- remote URLs (`https://…`; this provider fetches HTTPS only)
|
|
13
|
+
|
|
14
|
+
OpenCode itself expands `~/`, accepts absolute paths, and injects those file contents into the model prompt for **every** provider. This package does the same discovery when building Cursor `RequestContext.rules`.
|
|
15
|
+
|
|
16
|
+
So a project config like:
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"instructions": ["~/.ssh/id_rsa", "/etc/passwd"]
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
can cause those files to be read and sent to the model provider (OpenCode prompt path and/or this provider’s Cursor `RequestContext`). That is intentional OpenCode parity, not a Cursor-only hole. There is no path allowlist that rejects absolute/`~/` instruction paths (matching OpenCode; OpenCode has not treated this as a defect).
|
|
25
|
+
|
|
26
|
+
Treat project `opencode.json` as **trusted**. Do not open untrusted repositories with project config enabled if that is unacceptable.
|
|
27
|
+
|
|
28
|
+
## Mitigation: disable project config
|
|
29
|
+
|
|
30
|
+
To ignore project-level OpenCode config (including project `instructions` and project `AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` discovery), set:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export OPENCODE_DISABLE_PROJECT_CONFIG=1
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
OpenCode honors this flag when loading config and assembling prompts. This provider honors the same flag when collecting rules for Cursor `RequestContext`, so project `instructions` are not merged and project instruction files are not auto-discovered. Global config under `~/.config/opencode` (and `~/.claude/CLAUDE.md` when applicable) still applies.
|
|
37
|
+
|
|
38
|
+
Truthy values: `1` or `true` (case-insensitive), same as OpenCode.
|
|
39
|
+
|
|
40
|
+
## Related hardening in this provider
|
|
41
|
+
|
|
42
|
+
- Remote `instructions` URLs are **HTTPS-only** (`http://` is skipped). Redirects (including to a local proxy) are intentional and not blocked.
|
package/dist/auth.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export declare class AuthTimeoutError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
export declare function isExpiringSoon(jwt: string, thresholdS?: number): boolean;
|
|
17
17
|
export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
|
|
18
|
+
/** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
|
|
19
|
+
export declare function decodeJwtExpiryMs(jwt: string): number | null;
|
|
18
20
|
export declare function useAuthToken(token: string): {
|
|
19
21
|
accessToken: string;
|
|
20
22
|
};
|
|
@@ -45,4 +47,3 @@ export declare function generatePkceParams(): PkceParams;
|
|
|
45
47
|
export declare function generatePkceChallenge(verifier: string): Promise<string>;
|
|
46
48
|
export declare function buildLoginUrl(challenge: string, uuid: string, websiteUrl?: string): string;
|
|
47
49
|
export declare function pollForTokens(uuid: string, verifier: string, baseUrl?: string, signal?: AbortSignal, maxAttempts?: number): Promise<TokenPair>;
|
|
48
|
-
export declare function loginWithBrowser(websiteUrl?: string, apiBaseUrl?: string, signal?: AbortSignal): Promise<TokenPair>;
|
package/dist/auth.js
CHANGED
|
@@ -32,22 +32,35 @@ export class AuthTimeoutError extends Error {
|
|
|
32
32
|
}
|
|
33
33
|
// ── Helpers ──
|
|
34
34
|
export function isExpiringSoon(jwt, thresholdS = 300) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return (payload.exp * 1000 - Date.now()) < thresholdS * 1000;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
35
|
+
const payload = decodeJwtPayload(jwt);
|
|
36
|
+
if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
|
|
40
37
|
return true;
|
|
41
38
|
}
|
|
39
|
+
return payload.exp * 1000 - Date.now() < thresholdS * 1000;
|
|
42
40
|
}
|
|
43
41
|
export function decodeJwtPayload(jwt) {
|
|
44
42
|
try {
|
|
45
|
-
|
|
43
|
+
const segment = jwt.split(".")[1];
|
|
44
|
+
if (!segment)
|
|
45
|
+
return null;
|
|
46
|
+
const json = Buffer.from(segment, "base64url").toString("utf8");
|
|
47
|
+
const payload = JSON.parse(json);
|
|
48
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
|
49
|
+
return null;
|
|
50
|
+
return payload;
|
|
46
51
|
}
|
|
47
52
|
catch {
|
|
48
53
|
return null;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
56
|
+
/** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
|
|
57
|
+
export function decodeJwtExpiryMs(jwt) {
|
|
58
|
+
const payload = decodeJwtPayload(jwt);
|
|
59
|
+
if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return payload.exp * 1000;
|
|
63
|
+
}
|
|
51
64
|
function base64url(bytes) {
|
|
52
65
|
return btoa(String.fromCharCode(...bytes))
|
|
53
66
|
.replace(/\+/g, "-")
|
|
@@ -191,10 +204,3 @@ export async function pollForTokens(uuid, verifier, baseUrl = API_BASE, signal,
|
|
|
191
204
|
}
|
|
192
205
|
throw new AuthTimeoutError(`Poll timed out after ${maxAttempts} attempts (~5 min)`);
|
|
193
206
|
}
|
|
194
|
-
// ── Combined login (Mode C, one-shot) ──
|
|
195
|
-
export async function loginWithBrowser(websiteUrl, apiBaseUrl, signal) {
|
|
196
|
-
const params = generatePkceParams();
|
|
197
|
-
const challenge = await generatePkceChallenge(params.verifier);
|
|
198
|
-
const loginUrl = buildLoginUrl(challenge, params.uuid, websiteUrl);
|
|
199
|
-
return await pollForTokens(params.uuid, params.verifier, apiBaseUrl, signal);
|
|
200
|
-
}
|
package/dist/context/rules.d.ts
CHANGED
|
@@ -9,6 +9,10 @@ export type OpencodeJson = {
|
|
|
9
9
|
plugins?: string[];
|
|
10
10
|
mcp?: Record<string, unknown>;
|
|
11
11
|
};
|
|
12
|
+
/** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
|
|
13
|
+
export declare function isProjectConfigDisabled(): boolean;
|
|
14
|
+
/** Fetch a remote instruction with one deadline covering headers and body. */
|
|
15
|
+
export declare function fetchRemoteInstruction(url: string, timeoutMs?: number): Promise<string | undefined>;
|
|
12
16
|
export declare function loadMergedConfig(workspaceRoot: string): Promise<OpencodeJson>;
|
|
13
17
|
/**
|
|
14
18
|
* Collect OpenCode instruction files.
|
package/dist/context/rules.js
CHANGED
|
@@ -129,8 +129,40 @@ async function expandGlob(pattern, workspaceRoot) {
|
|
|
129
129
|
await walk(startDir, 0);
|
|
130
130
|
return out;
|
|
131
131
|
}
|
|
132
|
+
/** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
|
|
133
|
+
export function isProjectConfigDisabled() {
|
|
134
|
+
const value = process.env.OPENCODE_DISABLE_PROJECT_CONFIG?.toLowerCase();
|
|
135
|
+
return value === "true" || value === "1";
|
|
136
|
+
}
|
|
137
|
+
/** Fetch a remote instruction with one deadline covering headers and body. */
|
|
138
|
+
export async function fetchRemoteInstruction(url, timeoutMs = 5000) {
|
|
139
|
+
const ctrl = new AbortController();
|
|
140
|
+
const timer = setTimeout(() => ctrl.abort(), Math.max(1, timeoutMs));
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
143
|
+
if (!res.ok)
|
|
144
|
+
return undefined;
|
|
145
|
+
return await res.text();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
132
154
|
export async function loadMergedConfig(workspaceRoot) {
|
|
133
155
|
const globalConfig = await readJsonConfig(opencodeGlobalConfigDir());
|
|
156
|
+
if (isProjectConfigDisabled()) {
|
|
157
|
+
return {
|
|
158
|
+
...globalConfig,
|
|
159
|
+
instructions: [...(globalConfig.instructions ?? [])],
|
|
160
|
+
plugin: [...(globalConfig.plugin ?? [])],
|
|
161
|
+
plugins: [...(globalConfig.plugins ?? [])],
|
|
162
|
+
mcp: { ...(globalConfig.mcp ?? {}) },
|
|
163
|
+
permission: globalConfig.permission,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
134
166
|
const projectConfig = await readJsonConfig(workspaceRoot);
|
|
135
167
|
return {
|
|
136
168
|
...globalConfig,
|
|
@@ -162,33 +194,37 @@ export async function collectRules(workspaceRoot) {
|
|
|
162
194
|
seen.add(resolved);
|
|
163
195
|
rules.push(rule);
|
|
164
196
|
};
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
197
|
+
// Match OpenCode: OPENCODE_DISABLE_PROJECT_CONFIG skips project AGENTS/CLAUDE/CONTEXT
|
|
198
|
+
// discovery and project opencode.json (see loadMergedConfig).
|
|
199
|
+
if (!isProjectConfigDisabled()) {
|
|
200
|
+
for (const name of ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]) {
|
|
201
|
+
const hit = await findUp(name, workspaceRoot, worktree);
|
|
202
|
+
if (hit) {
|
|
203
|
+
await add(hit);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
170
206
|
}
|
|
171
207
|
}
|
|
172
208
|
await add(path.join(opencodeGlobalConfigDir(), "AGENTS.md"));
|
|
173
209
|
await add(path.join(homedir(), ".claude", "CLAUDE.md"));
|
|
174
210
|
for (const raw of config.instructions ?? []) {
|
|
175
211
|
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
|
212
|
+
// HTTPS-only. Redirects (incl. to a local proxy) are intentional — do not
|
|
213
|
+
// disable follow-redirects or reject localhost/private/metadata hosts.
|
|
214
|
+
let remoteUrl;
|
|
176
215
|
try {
|
|
177
|
-
|
|
178
|
-
const t = setTimeout(() => ctrl.abort(), 5000);
|
|
179
|
-
const res = await fetch(raw, { signal: ctrl.signal });
|
|
180
|
-
clearTimeout(t);
|
|
181
|
-
if (!res.ok)
|
|
182
|
-
continue;
|
|
183
|
-
const content = await res.text();
|
|
184
|
-
if (!content.trim() || seen.has(raw))
|
|
185
|
-
continue;
|
|
186
|
-
seen.add(raw);
|
|
187
|
-
rules.push({ fullPath: raw, content });
|
|
216
|
+
remoteUrl = new URL(raw);
|
|
188
217
|
}
|
|
189
218
|
catch {
|
|
190
|
-
|
|
219
|
+
continue;
|
|
191
220
|
}
|
|
221
|
+
if (remoteUrl.protocol !== "https:")
|
|
222
|
+
continue;
|
|
223
|
+
const content = await fetchRemoteInstruction(remoteUrl.href);
|
|
224
|
+
if (!content?.trim() || seen.has(raw))
|
|
225
|
+
continue;
|
|
226
|
+
seen.add(raw);
|
|
227
|
+
rules.push({ fullPath: raw, content });
|
|
192
228
|
continue;
|
|
193
229
|
}
|
|
194
230
|
const expanded = resolveHomeRelative(raw);
|
package/dist/debug.d.ts
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
+
/** Resolve the debug log path (env override or per-uid tmpdir default). */
|
|
2
|
+
export declare function resolveDebugLogPath(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
|
|
5
|
+
* Exported for tests; callers normally go through `trace`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureSecureDebugLog(filePath: string, options?: {
|
|
8
|
+
secureParent?: boolean;
|
|
9
|
+
}): void;
|
|
1
10
|
export declare function trace(msg: string): void;
|
package/dist/debug.js
CHANGED
|
@@ -1,20 +1,60 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
// Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
|
|
3
|
-
//
|
|
5
|
+
// Default path mirrors Cursor CLI: $TMPDIR/cursor-provider-logs-<uid>/debug-<pid>.log
|
|
6
|
+
// with directory mode 0o700 and file mode 0o600. Override with CURSOR_PROVIDER_DEBUG_FILE.
|
|
4
7
|
// Truncated once per process. Tokens / checksums should be redacted by callers.
|
|
5
8
|
const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
|
|
6
9
|
process.env.CURSOR_PROVIDER_DEBUG === "true";
|
|
7
|
-
const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
|
|
8
10
|
let _traceInitialized = false;
|
|
11
|
+
let _debugFile;
|
|
12
|
+
let _debugFileUsesManagedDirectory = false;
|
|
13
|
+
/** Resolve the debug log path (env override or per-uid tmpdir default). */
|
|
14
|
+
export function resolveDebugLogPath() {
|
|
15
|
+
if (process.env.CURSOR_PROVIDER_DEBUG_FILE) {
|
|
16
|
+
return process.env.CURSOR_PROVIDER_DEBUG_FILE;
|
|
17
|
+
}
|
|
18
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : process.pid;
|
|
19
|
+
return path.join(os.tmpdir(), `cursor-provider-logs-${uid}`, `debug-${process.pid}.log`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
|
|
23
|
+
* Exported for tests; callers normally go through `trace`.
|
|
24
|
+
*/
|
|
25
|
+
export function ensureSecureDebugLog(filePath, options = {}) {
|
|
26
|
+
const dir = path.dirname(filePath);
|
|
27
|
+
const secureParent = options.secureParent ?? true;
|
|
28
|
+
fs.mkdirSync(dir, secureParent
|
|
29
|
+
? { recursive: true, mode: 0o700 }
|
|
30
|
+
: { recursive: true });
|
|
31
|
+
if (secureParent) {
|
|
32
|
+
const stat = fs.lstatSync(dir);
|
|
33
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
34
|
+
throw new Error(`Debug log directory is not a real directory: ${dir}`);
|
|
35
|
+
}
|
|
36
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
37
|
+
throw new Error(`Debug log directory is not owned by the current user: ${dir}`);
|
|
38
|
+
}
|
|
39
|
+
fs.chmodSync(dir, 0o700);
|
|
40
|
+
}
|
|
41
|
+
fs.writeFileSync(filePath, "", { mode: 0o600 });
|
|
42
|
+
fs.chmodSync(filePath, 0o600);
|
|
43
|
+
}
|
|
9
44
|
export function trace(msg) {
|
|
10
45
|
if (!DEBUG_ENABLED)
|
|
11
46
|
return;
|
|
12
47
|
try {
|
|
48
|
+
if (!_debugFile) {
|
|
49
|
+
_debugFileUsesManagedDirectory = !process.env.CURSOR_PROVIDER_DEBUG_FILE;
|
|
50
|
+
_debugFile = resolveDebugLogPath();
|
|
51
|
+
}
|
|
13
52
|
if (!_traceInitialized) {
|
|
53
|
+
ensureSecureDebugLog(_debugFile, { secureParent: _debugFileUsesManagedDirectory });
|
|
54
|
+
fs.writeFileSync(_debugFile, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`, { mode: 0o600 });
|
|
14
55
|
_traceInitialized = true;
|
|
15
|
-
fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
|
|
16
56
|
}
|
|
17
|
-
fs.appendFileSync(
|
|
57
|
+
fs.appendFileSync(_debugFile, `[${new Date().toISOString()}] ${msg}\n`);
|
|
18
58
|
}
|
|
19
59
|
catch {
|
|
20
60
|
/* ignore */
|
package/dist/language-model.d.ts
CHANGED
|
@@ -87,12 +87,16 @@ export declare function cursorTurnEndedProviderMetadata(te: Record<string, unkno
|
|
|
87
87
|
};
|
|
88
88
|
};
|
|
89
89
|
/**
|
|
90
|
-
* Prior prompt turns for a seed ConversationStateStructure
|
|
91
|
-
*
|
|
92
|
-
* result
|
|
90
|
+
* Prior prompt turns for a seed ConversationStateStructure. Tool results must
|
|
91
|
+
* never be replayed as assistant-authored prose: that teaches the model to
|
|
92
|
+
* counterfeit `Tool result (...)` text instead of emitting a real tool call.
|
|
93
|
+
* Normal rebases omit old results; compaction can retain all results and
|
|
94
|
+
* interrupted continuations retain only the trailing live result suffix as
|
|
95
|
+
* explicit OpenCode-host observations.
|
|
93
96
|
*/
|
|
94
97
|
export declare function extractPromptHistory(prompt: LanguageModelV3CallOptions["prompt"], options?: {
|
|
95
98
|
preserveTrailingUser?: boolean;
|
|
99
|
+
toolResults?: "omit" | "all" | "trailing";
|
|
96
100
|
}): SeedHistoryMessage[];
|
|
97
101
|
/** OpenCode session id header, if present. */
|
|
98
102
|
export declare function opencodeSessionKey(callOptions: LanguageModelV3CallOptions): string | undefined;
|
package/dist/language-model.js
CHANGED
|
@@ -439,7 +439,10 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
439
439
|
const systemPrompt = interactionGuidance
|
|
440
440
|
? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
|
|
441
441
|
: baseSystemPrompt;
|
|
442
|
-
const history = extractPromptHistory(prompt, {
|
|
442
|
+
const history = extractPromptHistory(prompt, {
|
|
443
|
+
preserveTrailingUser: recovery,
|
|
444
|
+
toolResults: isCompaction ? "all" : (recovery ? "trailing" : "omit"),
|
|
445
|
+
});
|
|
443
446
|
await loadAvailableModels();
|
|
444
447
|
// Resolve the region-specific Run stream origin once per process (memoized
|
|
445
448
|
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
@@ -847,6 +850,56 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
847
850
|
return false;
|
|
848
851
|
}
|
|
849
852
|
};
|
|
853
|
+
/**
|
|
854
|
+
* Cursor's streamed edit handshake reads the target before it sends the
|
|
855
|
+
* replacement through write_args. For a new file that read naturally fails,
|
|
856
|
+
* which makes the model abandon the edit and fall back to a shell heredoc.
|
|
857
|
+
* Treat only a missing target correlated to an edit_tool_call as an empty
|
|
858
|
+
* file, allowing Cursor to continue to the ordinary OpenCode write call.
|
|
859
|
+
*/
|
|
860
|
+
const recoverMissingEditRead = (parsed, displayCallId) => {
|
|
861
|
+
if (!displayCallId ||
|
|
862
|
+
parsed.resultField !== "read_result" ||
|
|
863
|
+
parsed.toolName !== "read" ||
|
|
864
|
+
!advertisedToolNameSet.has("write"))
|
|
865
|
+
return false;
|
|
866
|
+
const stored = session.displayToolCalls.get(displayCallId);
|
|
867
|
+
const display = parseDisplayToolCall(displayCallId, stored);
|
|
868
|
+
if (display?.variant !== "edit_tool_call" || display.bridgeable === false)
|
|
869
|
+
return false;
|
|
870
|
+
const requestedPath = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
871
|
+
const editPath = typeof display.args.path === "string" ? display.args.path : "";
|
|
872
|
+
if (!requestedPath || !editPath)
|
|
873
|
+
return false;
|
|
874
|
+
const workspaceRoot = typeof session.requestContext.workspace_project_dir === "string"
|
|
875
|
+
? session.requestContext.workspace_project_dir
|
|
876
|
+
: process.cwd();
|
|
877
|
+
const resolvePath = (value) => path.resolve(workspaceRoot, value);
|
|
878
|
+
const absolutePath = resolvePath(requestedPath);
|
|
879
|
+
if (absolutePath !== resolvePath(editPath) || fs.existsSync(absolutePath))
|
|
880
|
+
return false;
|
|
881
|
+
try {
|
|
882
|
+
for (const frame of buildExecClientMessages({
|
|
883
|
+
execId: parsed.id,
|
|
884
|
+
resultField: parsed.resultField,
|
|
885
|
+
output: "",
|
|
886
|
+
toolName: parsed.toolName,
|
|
887
|
+
resultMetadata: { path: requestedPath },
|
|
888
|
+
})) {
|
|
889
|
+
session.stream.write(frame);
|
|
890
|
+
}
|
|
891
|
+
trace(`exec: missing edit target treated as empty file id=${parsed.id} ` +
|
|
892
|
+
`path=${JSON.stringify(requestedPath)}; awaiting write_args`);
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
const error = new Error(`Failed to recover Cursor edit read for a new file: ${e.message}`);
|
|
897
|
+
trace(`exec: edit read recovery FAILED ${error.message}`);
|
|
898
|
+
safeError(error);
|
|
899
|
+
sessionManager.close(session);
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
};
|
|
850
903
|
/** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
|
|
851
904
|
const closeOpenSpans = () => {
|
|
852
905
|
for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
|
|
@@ -1153,7 +1206,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1153
1206
|
trace(`exec request_context: replied`);
|
|
1154
1207
|
}
|
|
1155
1208
|
catch (e) {
|
|
1156
|
-
|
|
1209
|
+
const error = new Error(`Failed to answer Cursor request_context probe: ${e.message}`);
|
|
1210
|
+
trace(`exec request_context: write FAILED ${error.message}`);
|
|
1211
|
+
safeError(error);
|
|
1212
|
+
sessionManager.close(session);
|
|
1213
|
+
return;
|
|
1157
1214
|
}
|
|
1158
1215
|
}
|
|
1159
1216
|
else if (esm.mcp_state_exec_args) {
|
|
@@ -1179,10 +1236,6 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1179
1236
|
else {
|
|
1180
1237
|
const parsed = parseExecServerMessage(esm);
|
|
1181
1238
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
1182
|
-
if (displayCallId) {
|
|
1183
|
-
session.displayToolCalls.delete(displayCallId);
|
|
1184
|
-
trace(`exec: claimed display callId=${displayCallId}`);
|
|
1185
|
-
}
|
|
1186
1239
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
1187
1240
|
if (parsed) {
|
|
1188
1241
|
if (parsed.localError) {
|
|
@@ -1219,6 +1272,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1219
1272
|
return;
|
|
1220
1273
|
continue;
|
|
1221
1274
|
}
|
|
1275
|
+
if (recoverMissingEditRead(parsed, displayCallId))
|
|
1276
|
+
continue;
|
|
1277
|
+
if (displayCallId) {
|
|
1278
|
+
session.displayToolCalls.delete(displayCallId);
|
|
1279
|
+
trace(`exec: claimed display callId=${displayCallId}`);
|
|
1280
|
+
}
|
|
1222
1281
|
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
1223
1282
|
if (parsed.resultField === "shell_stream") {
|
|
1224
1283
|
registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
|
|
@@ -1257,7 +1316,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1257
1316
|
else if (interactionQuery) {
|
|
1258
1317
|
// InteractionQuery is a must-reply channel, just like exec and KV. AI
|
|
1259
1318
|
// SDK has no Cursor-specific UI callback, so answer immediately with the
|
|
1260
|
-
// conservative headless policy from protocol/interactions.ts
|
|
1319
|
+
// conservative headless policy from protocol/interactions.ts (including
|
|
1320
|
+
// F14 create_plan auto-ack / empty plan_uri for CLI headless parity).
|
|
1261
1321
|
try {
|
|
1262
1322
|
const handled = handleInteractionQuery(interactionQuery, payload);
|
|
1263
1323
|
session.stream.write(handled.reply);
|
|
@@ -1290,7 +1350,13 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1290
1350
|
`found=${handled.found} echoed=${!!handled.echoed} ` +
|
|
1291
1351
|
`sessionBlobs=${session.blobs.size} convBlobs=${conversationBlobCount(session.conversationId)}`);
|
|
1292
1352
|
}
|
|
1293
|
-
catch {
|
|
1353
|
+
catch (e) {
|
|
1354
|
+
const error = new Error(`Failed to answer Cursor KV blob request: ${e.message}`);
|
|
1355
|
+
trace(`kv: write FAILED ${error.message}`);
|
|
1356
|
+
safeError(error);
|
|
1357
|
+
sessionManager.close(session);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1294
1360
|
}
|
|
1295
1361
|
}
|
|
1296
1362
|
}
|
|
@@ -1423,6 +1489,11 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1423
1489
|
if (names.has("webfetch")) {
|
|
1424
1490
|
instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
|
|
1425
1491
|
}
|
|
1492
|
+
if (names.has("write")) {
|
|
1493
|
+
instructions.push(names.has("edit")
|
|
1494
|
+
? "- For file changes, use OpenCode `edit` for targeted changes to existing files and `write` to create files or intentionally replace complete contents; do not use shell, Python, or heredocs to change file content while these tools are available."
|
|
1495
|
+
: "- Use OpenCode `write` for file-content changes; do not use shell, Python, or heredocs to change file content while it is available.");
|
|
1496
|
+
}
|
|
1426
1497
|
return [
|
|
1427
1498
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1428
1499
|
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
@@ -1469,13 +1540,24 @@ export function cursorTurnEndedProviderMetadata(te) {
|
|
|
1469
1540
|
};
|
|
1470
1541
|
}
|
|
1471
1542
|
/**
|
|
1472
|
-
* Prior prompt turns for a seed ConversationStateStructure
|
|
1473
|
-
*
|
|
1474
|
-
* result
|
|
1543
|
+
* Prior prompt turns for a seed ConversationStateStructure. Tool results must
|
|
1544
|
+
* never be replayed as assistant-authored prose: that teaches the model to
|
|
1545
|
+
* counterfeit `Tool result (...)` text instead of emitting a real tool call.
|
|
1546
|
+
* Normal rebases omit old results; compaction can retain all results and
|
|
1547
|
+
* interrupted continuations retain only the trailing live result suffix as
|
|
1548
|
+
* explicit OpenCode-host observations.
|
|
1475
1549
|
*/
|
|
1476
1550
|
export function extractPromptHistory(prompt, options) {
|
|
1477
1551
|
const out = [];
|
|
1478
|
-
|
|
1552
|
+
const toolResults = options?.toolResults ?? "omit";
|
|
1553
|
+
let trailingToolStart = prompt.length;
|
|
1554
|
+
if (toolResults === "trailing") {
|
|
1555
|
+
while (trailingToolStart > 0 && prompt[trailingToolStart - 1]?.role === "tool") {
|
|
1556
|
+
trailingToolStart--;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
for (let messageIndex = 0; messageIndex < prompt.length; messageIndex++) {
|
|
1560
|
+
const m = prompt[messageIndex];
|
|
1479
1561
|
if (m.role === "system") {
|
|
1480
1562
|
if (typeof m.content === "string" && m.content.length > 0) {
|
|
1481
1563
|
out.push({ role: "system", content: m.content });
|
|
@@ -1495,18 +1577,26 @@ export function extractPromptHistory(prompt, options) {
|
|
|
1495
1577
|
continue;
|
|
1496
1578
|
}
|
|
1497
1579
|
if (m.role === "tool" && Array.isArray(m.content)) {
|
|
1580
|
+
if (toolResults === "omit" ||
|
|
1581
|
+
(toolResults === "trailing" && messageIndex < trailingToolStart))
|
|
1582
|
+
continue;
|
|
1498
1583
|
const results = [];
|
|
1499
1584
|
for (const part of m.content) {
|
|
1500
1585
|
const p = part;
|
|
1501
1586
|
if (p.type !== "tool-result")
|
|
1502
1587
|
continue;
|
|
1503
1588
|
const toolName = typeof p.toolName === "string" && p.toolName ? p.toolName : "tool";
|
|
1589
|
+
const toolCallId = typeof p.toolCallId === "string" ? p.toolCallId : "";
|
|
1504
1590
|
const result = toolResultOutputToText(p.output);
|
|
1505
|
-
|
|
1506
|
-
|
|
1591
|
+
results.push(formatSeedToolObservation({
|
|
1592
|
+
toolName,
|
|
1593
|
+
toolCallId,
|
|
1594
|
+
output: result.text,
|
|
1595
|
+
isError: result.isError,
|
|
1596
|
+
}));
|
|
1507
1597
|
}
|
|
1508
1598
|
if (results.length > 0)
|
|
1509
|
-
appendSeedHistory(out, "
|
|
1599
|
+
appendSeedHistory(out, "user", results.join("\n\n"));
|
|
1510
1600
|
}
|
|
1511
1601
|
}
|
|
1512
1602
|
// Live user message is the Run action, not seed history.
|
|
@@ -1515,6 +1605,15 @@ export function extractPromptHistory(prompt, options) {
|
|
|
1515
1605
|
}
|
|
1516
1606
|
return out;
|
|
1517
1607
|
}
|
|
1608
|
+
function formatSeedToolObservation(input) {
|
|
1609
|
+
const metadata = JSON.stringify({
|
|
1610
|
+
source: "opencode-tool",
|
|
1611
|
+
tool: input.toolName,
|
|
1612
|
+
callId: input.toolCallId,
|
|
1613
|
+
status: input.isError ? "error" : "completed",
|
|
1614
|
+
});
|
|
1615
|
+
return `OpenCode host observation ${metadata}:\n${input.output}`;
|
|
1616
|
+
}
|
|
1518
1617
|
function extractAssistantHistoryText(msg) {
|
|
1519
1618
|
const content = msg.content;
|
|
1520
1619
|
if (typeof content === "string")
|
package/dist/plugin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
2
|
-
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl,
|
|
2
|
+
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
|
|
3
3
|
import { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
|
|
4
4
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
5
|
import { readStoredAuth } from "./context/auth-store.js";
|
|
@@ -275,7 +275,7 @@ export async function CursorPlugin(input) {
|
|
|
275
275
|
type: "oauth",
|
|
276
276
|
access: newTokens.accessToken,
|
|
277
277
|
refresh: newTokens.refreshToken,
|
|
278
|
-
expires:
|
|
278
|
+
expires: decodeJwtExpiryMs(newTokens.accessToken) ?? Date.now(),
|
|
279
279
|
...(extras.accountId !== undefined ? { accountId: extras.accountId } : {}),
|
|
280
280
|
...(extras.enterpriseUrl !== undefined ? { enterpriseUrl: extras.enterpriseUrl } : {}),
|
|
281
281
|
});
|
|
@@ -397,7 +397,7 @@ export async function CursorPlugin(input) {
|
|
|
397
397
|
provider: CURSOR_PROVIDER_ID,
|
|
398
398
|
access: result.accessToken,
|
|
399
399
|
refresh: result.refreshToken,
|
|
400
|
-
expires:
|
|
400
|
+
expires: decodeJwtExpiryMs(result.accessToken) ?? Date.now(),
|
|
401
401
|
};
|
|
402
402
|
},
|
|
403
403
|
};
|
|
@@ -469,9 +469,3 @@ export async function CursorPlugin(input) {
|
|
|
469
469
|
},
|
|
470
470
|
};
|
|
471
471
|
}
|
|
472
|
-
function decodeExpFromJwt(jwt) {
|
|
473
|
-
const payload = decodeJwtPayload(jwt);
|
|
474
|
-
if (payload && typeof payload.exp === "number")
|
|
475
|
-
return payload.exp * 1000;
|
|
476
|
-
return Date.now() + 3600_000;
|
|
477
|
-
}
|
|
@@ -19,5 +19,12 @@ export declare class UnsupportedInteractionQueryError extends Error {
|
|
|
19
19
|
* instead of accidentally restoring the heartbeat-only deadlock.
|
|
20
20
|
*/
|
|
21
21
|
export declare function inspectInteractionQueryWire(agentServerPayload: Uint8Array): InteractionQueryWireInfo;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Build the immediate typed response required by Cursor's Run RPC.
|
|
24
|
+
*
|
|
25
|
+
* OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
|
|
26
|
+
* here with a conservative headless policy (reject UI-bound prompts; ack a few
|
|
27
|
+
* no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
|
|
28
|
+
* CLI headless mode.
|
|
29
|
+
*/
|
|
23
30
|
export declare function handleInteractionQuery(query: Record<string, unknown>, agentServerPayload: Uint8Array): HandledInteraction;
|
|
@@ -51,7 +51,14 @@ export function inspectInteractionQueryWire(agentServerPayload) {
|
|
|
51
51
|
variantName: variantField === undefined ? undefined : variantNames[variantField],
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Build the immediate typed response required by Cursor's Run RPC.
|
|
56
|
+
*
|
|
57
|
+
* OpenCode has no Cursor UI callbacks, so every InteractionQuery is answered
|
|
58
|
+
* here with a conservative headless policy (reject UI-bound prompts; ack a few
|
|
59
|
+
* no-UI cases). See case 7 / F14 for create_plan auto-ack parity with Cursor
|
|
60
|
+
* CLI headless mode.
|
|
61
|
+
*/
|
|
55
62
|
export function handleInteractionQuery(query, agentServerPayload) {
|
|
56
63
|
const info = inspectInteractionQueryWire(agentServerPayload);
|
|
57
64
|
if (info.id === undefined || info.variantField === undefined || !info.variantName) {
|
|
@@ -80,8 +87,14 @@ export function handleInteractionQuery(query, agentServerPayload) {
|
|
|
80
87
|
outcome = "rejected";
|
|
81
88
|
break;
|
|
82
89
|
case 7:
|
|
90
|
+
// F14: create_plan_request_query auto-ack (Cursor CLI headless parity).
|
|
83
91
|
// Cursor CLI's headless fallback acknowledges plan creation without a
|
|
84
|
-
// client-side URI; the plan remains in
|
|
92
|
+
// client-side URI (`success` + empty `plan_uri`); the plan remains in
|
|
93
|
+
// conversation state / checkpoints. This provider mirrors that reply so
|
|
94
|
+
// the Run RPC does not deadlock waiting for UI approval. Impact: Cursor
|
|
95
|
+
// may treat the plan as accepted without an OpenCode UI confirm; local
|
|
96
|
+
// tool execution is still gated by OpenCode permissions. Do not change
|
|
97
|
+
// this to reject/prompt without CLI parity evidence.
|
|
85
98
|
response = { create_plan_request_response: { result: { success: {}, plan_uri: "" } } };
|
|
86
99
|
outcome = "acknowledged";
|
|
87
100
|
break;
|
|
@@ -357,8 +357,11 @@ export function createMessageTypes() {
|
|
|
357
357
|
{ id: 2, name: "content", type: "string" },
|
|
358
358
|
{ id: 3, name: "total_lines", type: "int32" },
|
|
359
359
|
{ id: 4, name: "file_size", type: "int64" },
|
|
360
|
+
{ id: 5, name: "data", type: "bytes" },
|
|
360
361
|
{ id: 6, name: "truncated", type: "bool" },
|
|
361
|
-
|
|
362
|
+
{ id: 7, name: "output_blob_id", type: "bytes" },
|
|
363
|
+
{ id: 8, name: "range_applied", type: "bool" },
|
|
364
|
+
], [{ name: "output", fields: ["content", "data"] }]);
|
|
362
365
|
addType(root, "ReadError", [
|
|
363
366
|
{ id: 1, name: "path", type: "string" },
|
|
364
367
|
{ id: 2, name: "error", type: "string" },
|
|
@@ -10,8 +10,8 @@ export type RunRequestInput = {
|
|
|
10
10
|
systemPrompt?: string;
|
|
11
11
|
/**
|
|
12
12
|
* Prior chat turns for a seed ConversationStateStructure (no checkpoint).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Tool outputs, when required for compaction/recovery, are represented as
|
|
14
|
+
* user-role OpenCode host observations rather than assistant-authored prose.
|
|
15
15
|
*/
|
|
16
16
|
history?: SeedHistoryMessage[];
|
|
17
17
|
/**
|
|
@@ -7,7 +7,12 @@ const TODO_STATUS = {
|
|
|
7
7
|
3: "completed",
|
|
8
8
|
4: "cancelled",
|
|
9
9
|
};
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Cursor ToolCall oneof field → default OpenCode tool id.
|
|
12
|
+
*
|
|
13
|
+
* F11: `delete_tool_call` has no OpenCode builtin; it remaps to bash (see the
|
|
14
|
+
* delete_tool_call branch below for `rm -f -- <path>`).
|
|
15
|
+
*/
|
|
11
16
|
const VARIANT_TO_OPENCODE = {
|
|
12
17
|
shell_tool_call: "bash",
|
|
13
18
|
delete_tool_call: "bash",
|
|
@@ -121,6 +126,10 @@ export function parseDisplayToolCall(callId, toolCall) {
|
|
|
121
126
|
args: mcpArgs,
|
|
122
127
|
};
|
|
123
128
|
}
|
|
129
|
+
// create_plan_tool_call here is display-state mirroring into todowrite.
|
|
130
|
+
// Separately, InteractionQuery create_plan_request_query is auto-acked in
|
|
131
|
+
// protocol/interactions.ts (F14 / CLI headless parity) — that ack is not an
|
|
132
|
+
// execution of this display payload.
|
|
124
133
|
if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
|
|
125
134
|
const result = asRecord(payload.result);
|
|
126
135
|
const success = asRecord(result?.success);
|
|
@@ -238,6 +247,7 @@ export function parseDisplayToolCall(callId, toolCall) {
|
|
|
238
247
|
args: {},
|
|
239
248
|
};
|
|
240
249
|
}
|
|
250
|
+
// F11: display-path delete → bash `rm -f` (OpenCode has no delete tool).
|
|
241
251
|
if (variant === "delete_tool_call") {
|
|
242
252
|
const path = typeof args.path === "string" ? args.path : "";
|
|
243
253
|
return {
|
package/dist/protocol/tools.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
1
2
|
import { encodeMessage } from "./messages.js";
|
|
2
3
|
import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
|
|
3
4
|
import { trace } from "../debug.js";
|
|
@@ -133,8 +134,13 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
|
|
|
133
134
|
// glob_args on the exec channel — Cursor's EditToolCall is display-only, and
|
|
134
135
|
// glob/edit from opencode are advertised as MCP tools and arrive as mcp_args.
|
|
135
136
|
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
137
|
+
// Design note (F11): Cursor has native delete / background-shell tools; OpenCode
|
|
138
|
+
// does not. This provider intentionally remaps:
|
|
139
|
+
// - delete_args → bash `rm -f -- <quoted-path>`
|
|
140
|
+
// - background_shell_spawn_args → bash + nohup detach wrapper
|
|
141
|
+
// Permissions still flow through OpenCode's advertised `bash` tool. Soft-
|
|
142
|
+
// background / detached children can outlive the OpenCode tool call; leftover
|
|
143
|
+
// process cleanup is left to the user / OS. Arg key remapping happens below.
|
|
138
144
|
const cursorToolToOpencode = {
|
|
139
145
|
read_args: "read",
|
|
140
146
|
write_args: "write",
|
|
@@ -229,6 +235,9 @@ export function parseExecServerMessage(msg) {
|
|
|
229
235
|
if (!resultField)
|
|
230
236
|
return undefined;
|
|
231
237
|
const execId = msg.exec_id ?? "";
|
|
238
|
+
// F11: Cursor native background shell → OpenCode bash. The wrapper detaches
|
|
239
|
+
// via nohup immediately; OpenCode only sees the spawn marker (pid + log path).
|
|
240
|
+
// Residual: the child can keep running after this tool call completes.
|
|
232
241
|
if (execVariant === "background_shell_spawn_args") {
|
|
233
242
|
const raw = msg.background_shell_spawn_args ?? {};
|
|
234
243
|
const command = str(raw.command);
|
|
@@ -328,9 +337,16 @@ export function parseExecServerMessage(msg) {
|
|
|
328
337
|
if (!toolName)
|
|
329
338
|
return undefined;
|
|
330
339
|
const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
|
|
340
|
+
const rawArgs = msg[execVariant] ?? {};
|
|
331
341
|
const resultMetadata = execVariant === "shell_stream_args"
|
|
332
|
-
? shellStreamResultMetadata(
|
|
333
|
-
:
|
|
342
|
+
? shellStreamResultMetadata(rawArgs)
|
|
343
|
+
: execVariant === "read_args"
|
|
344
|
+
? {
|
|
345
|
+
path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
|
|
346
|
+
...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
|
|
347
|
+
...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
|
|
348
|
+
}
|
|
349
|
+
: undefined;
|
|
334
350
|
if (resultMetadata
|
|
335
351
|
&& resultMetadata.timeout_behavior !== 2
|
|
336
352
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -390,7 +406,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
390
406
|
const filePath = str(cleaned.path) ?? str(cleaned.filePath);
|
|
391
407
|
return { toolName: "read", args: filePath ? { filePath } : {} };
|
|
392
408
|
}
|
|
393
|
-
//
|
|
409
|
+
// F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
|
|
410
|
+
// we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
|
|
411
|
+
// as any other bash tool call.
|
|
394
412
|
if (execVariant === "delete_args") {
|
|
395
413
|
const target = str(cleaned.path) ?? str(cleaned.filePath);
|
|
396
414
|
return {
|
|
@@ -523,10 +541,15 @@ function shellQuote(s) {
|
|
|
523
541
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
524
542
|
}
|
|
525
543
|
/**
|
|
544
|
+
* F11 / background_shell_spawn_args helper.
|
|
545
|
+
*
|
|
526
546
|
* OpenCode's bash tool is foreground-only. Detach the requested command inside
|
|
527
|
-
* that one foreground call and print a private marker containing
|
|
528
|
-
* PID and log path. With stdin and all output redirected, the host
|
|
529
|
-
* return immediately instead of retaining OpenCode's tool pipe.
|
|
547
|
+
* that one foreground call (`nohup … &`) and print a private marker containing
|
|
548
|
+
* the spawned PID and log path. With stdin and all output redirected, the host
|
|
549
|
+
* shell can return immediately instead of retaining OpenCode's tool pipe.
|
|
550
|
+
*
|
|
551
|
+
* Residual: the detached child is not reaped by this provider after OpenCode
|
|
552
|
+
* completes the tool call; cleanup is left to the user / OS.
|
|
530
553
|
*/
|
|
531
554
|
function buildBackgroundShellCommand(command) {
|
|
532
555
|
return [
|
|
@@ -735,20 +758,29 @@ export function unwrapReadOutput(output) {
|
|
|
735
758
|
*/
|
|
736
759
|
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata) {
|
|
737
760
|
switch (resultField) {
|
|
738
|
-
case "read_result":
|
|
761
|
+
case "read_result": {
|
|
762
|
+
const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
|
|
739
763
|
if (error)
|
|
740
|
-
return { error: { path:
|
|
764
|
+
return { error: { path: readPath, error } };
|
|
741
765
|
// Strip opencode's <path>/<content> envelope so Cursor's model receives
|
|
742
766
|
// raw file content and can't echo the wrapper into subsequent writes.
|
|
743
767
|
// reads route here only for native read_args; most arrive via mcp_result.
|
|
744
768
|
const content = unwrapReadOutput(output);
|
|
769
|
+
const statPath = extractPathTag(output) ?? readPath;
|
|
770
|
+
const outputMetadata = parseOpenCodeReadMetadata(output);
|
|
771
|
+
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
772
|
+
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
745
773
|
return {
|
|
746
774
|
success: {
|
|
747
|
-
path:
|
|
775
|
+
path: readPath,
|
|
748
776
|
content,
|
|
749
|
-
total_lines:
|
|
777
|
+
total_lines: totalLines,
|
|
778
|
+
file_size: readFileSize(statPath),
|
|
779
|
+
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
780
|
+
range_applied: rangeApplied,
|
|
750
781
|
},
|
|
751
782
|
};
|
|
783
|
+
}
|
|
752
784
|
case "grep_result": {
|
|
753
785
|
if (error)
|
|
754
786
|
return { error: { error } };
|
|
@@ -913,6 +945,93 @@ function extractPathTag(output) {
|
|
|
913
945
|
const m = output.match(/<path>([^<]+)<\/path>/);
|
|
914
946
|
return m?.[1];
|
|
915
947
|
}
|
|
948
|
+
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
949
|
+
function parseOpenCodeReadMetadata(output) {
|
|
950
|
+
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(output);
|
|
951
|
+
if (showing) {
|
|
952
|
+
return {
|
|
953
|
+
startLine: Number(showing[1]),
|
|
954
|
+
endLine: Number(showing[2]),
|
|
955
|
+
...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
|
|
956
|
+
outputCapped: output.includes("(Output capped at "),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
const complete = /\(End of file - total (\d+) lines?\)/.exec(output);
|
|
960
|
+
if (complete)
|
|
961
|
+
return { totalLines: Number(complete[1]) };
|
|
962
|
+
return {};
|
|
963
|
+
}
|
|
964
|
+
function readRangeApplied(resultMetadata, totalLines) {
|
|
965
|
+
const offset = num(resultMetadata?.offset);
|
|
966
|
+
const limit = num(resultMetadata?.limit);
|
|
967
|
+
if (offset === undefined && limit === undefined)
|
|
968
|
+
return false;
|
|
969
|
+
if (totalLines === 0)
|
|
970
|
+
return false;
|
|
971
|
+
const startLine = offset ?? 1;
|
|
972
|
+
return startLine < 0 || startLine <= totalLines;
|
|
973
|
+
}
|
|
974
|
+
function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
975
|
+
if (outputMetadata.outputCapped)
|
|
976
|
+
return true;
|
|
977
|
+
const returnedEnd = outputMetadata.endLine;
|
|
978
|
+
if (returnedEnd === undefined || totalLines === 0)
|
|
979
|
+
return false;
|
|
980
|
+
const offset = num(resultMetadata?.offset);
|
|
981
|
+
const limit = num(resultMetadata?.limit);
|
|
982
|
+
const startLine = offset ?? 1;
|
|
983
|
+
if (startLine < 0)
|
|
984
|
+
return false;
|
|
985
|
+
const expectedEnd = limit === undefined
|
|
986
|
+
? totalLines
|
|
987
|
+
: Math.min(totalLines, Math.max(1, startLine) + limit - 1);
|
|
988
|
+
return returnedEnd < expectedEnd;
|
|
989
|
+
}
|
|
990
|
+
function readFileSize(filePath) {
|
|
991
|
+
if (!filePath)
|
|
992
|
+
return 0;
|
|
993
|
+
try {
|
|
994
|
+
return fs.statSync(filePath).size;
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
return 0;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function readFileLineCount(filePath) {
|
|
1001
|
+
if (!filePath)
|
|
1002
|
+
return undefined;
|
|
1003
|
+
let fd;
|
|
1004
|
+
try {
|
|
1005
|
+
fd = fs.openSync(filePath, "r");
|
|
1006
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
1007
|
+
let totalBytes = 0;
|
|
1008
|
+
let lines = 1;
|
|
1009
|
+
while (true) {
|
|
1010
|
+
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null);
|
|
1011
|
+
if (bytesRead === 0)
|
|
1012
|
+
break;
|
|
1013
|
+
totalBytes += bytesRead;
|
|
1014
|
+
for (let index = 0; index < bytesRead; index++) {
|
|
1015
|
+
if (buffer[index] === 0x0a)
|
|
1016
|
+
lines++;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return totalBytes === 0 ? 0 : lines;
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return undefined;
|
|
1023
|
+
}
|
|
1024
|
+
finally {
|
|
1025
|
+
if (fd !== undefined) {
|
|
1026
|
+
try {
|
|
1027
|
+
fs.closeSync(fd);
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
/* best effort */
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
916
1035
|
function extractPathLines(output) {
|
|
917
1036
|
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
918
1037
|
// Prefer absolute / relative path-looking lines; fall back to all non-empty.
|
package/dist/shell-timeout.d.ts
CHANGED
|
@@ -27,9 +27,16 @@ export declare function shellPolicyFromMetadata(metadata: Record<string, unknown
|
|
|
27
27
|
/** Register a Cursor shell request before OpenCode executes its emitted tool call. */
|
|
28
28
|
export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
|
|
29
29
|
/**
|
|
30
|
+
* F11 / soft-background helper.
|
|
31
|
+
*
|
|
30
32
|
* Run a Cursor soft-background command for its foreground window, then leave
|
|
31
|
-
* it detached if still alive. The sentinel is removed by the after
|
|
32
|
-
* OpenCode stores/renders the result.
|
|
33
|
+
* it detached (`nohup`) if still alive. The sentinel is removed by the after
|
|
34
|
+
* hook before OpenCode stores/renders the result.
|
|
35
|
+
*
|
|
36
|
+
* This approximates Cursor's TIMEOUT_BACKGROUND semantics through OpenCode's
|
|
37
|
+
* foreground-only bash tool. Residual: after OpenCode returns, the child (and
|
|
38
|
+
* optional hard-timeout watchdog) may still be running; this provider does not
|
|
39
|
+
* reap leftover processes — cleanup is left to the user / OS.
|
|
33
40
|
*/
|
|
34
41
|
export declare function buildSoftBackgroundCommand(policy: CursorShellPolicy): string;
|
|
35
42
|
/** Mutate OpenCode Bash args before execution when Cursor requested soft backgrounding. */
|
package/dist/shell-timeout.js
CHANGED
|
@@ -50,9 +50,16 @@ function shellQuote(value) {
|
|
|
50
50
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
+
* F11 / soft-background helper.
|
|
54
|
+
*
|
|
53
55
|
* Run a Cursor soft-background command for its foreground window, then leave
|
|
54
|
-
* it detached if still alive. The sentinel is removed by the after
|
|
55
|
-
* OpenCode stores/renders the result.
|
|
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.
|
|
56
63
|
*/
|
|
57
64
|
export function buildSoftBackgroundCommand(policy) {
|
|
58
65
|
const polls = Math.ceil(policy.timeoutMs / POLL_INTERVAL_MS);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"dist",
|
|
46
46
|
"LICENSE",
|
|
47
47
|
"DISCLAIMER.md",
|
|
48
|
+
"SECURITY.md",
|
|
48
49
|
"README.md"
|
|
49
50
|
],
|
|
50
51
|
"scripts": {
|
|
@@ -68,4 +69,4 @@
|
|
|
68
69
|
"@types/node": "^22.15.3",
|
|
69
70
|
"typescript": "^5.8.3"
|
|
70
71
|
}
|
|
71
|
-
}
|
|
72
|
+
}
|