cursor-opencode-provider 0.5.0 → 0.6.1

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 (53) hide show
  1. package/README.md +22 -13
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +3 -1
  6. package/dist/context/build.js +4 -2
  7. package/dist/context/index.d.ts +1 -1
  8. package/dist/context/index.js +1 -1
  9. package/dist/context/paths.d.ts +34 -53
  10. package/dist/context/paths.js +54 -118
  11. package/dist/context/plugins.js +1 -0
  12. package/dist/context/rules.js +2 -1
  13. package/dist/context/skills.js +1 -0
  14. package/dist/image-save-tool.d.ts +19 -0
  15. package/dist/image-save-tool.js +25 -0
  16. package/dist/image-save.d.ts +58 -0
  17. package/dist/image-save.js +189 -0
  18. package/dist/image-staging.d.ts +59 -0
  19. package/dist/image-staging.js +91 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/language-model.d.ts +3 -2
  22. package/dist/language-model.js +589 -41
  23. package/dist/plan-execution-kickoff.d.ts +55 -0
  24. package/dist/plan-execution-kickoff.js +162 -0
  25. package/dist/plugin-opencode2.js +52 -3
  26. package/dist/plugin-v2.d.ts +11 -9
  27. package/dist/plugin-v2.js +6 -13
  28. package/dist/plugin.d.ts +9 -0
  29. package/dist/plugin.js +61 -5
  30. package/dist/pricing-data.d.ts +16 -4
  31. package/dist/pricing-data.js +16 -4
  32. package/dist/protocol/ask-question.d.ts +133 -0
  33. package/dist/protocol/ask-question.js +320 -0
  34. package/dist/protocol/conversation-bind.d.ts +1 -0
  35. package/dist/protocol/conversation-bind.js +3 -0
  36. package/dist/protocol/create-plan.d.ts +141 -0
  37. package/dist/protocol/create-plan.js +373 -0
  38. package/dist/protocol/generate-image.d.ts +54 -0
  39. package/dist/protocol/generate-image.js +101 -0
  40. package/dist/protocol/interactions.d.ts +110 -8
  41. package/dist/protocol/interactions.js +295 -35
  42. package/dist/protocol/messages.js +118 -6
  43. package/dist/protocol/struct.js +1 -1
  44. package/dist/protocol/switch-mode.d.ts +178 -0
  45. package/dist/protocol/switch-mode.js +417 -0
  46. package/dist/protocol/tool-call-bridge.js +10 -5
  47. package/dist/protocol/tools.d.ts +27 -11
  48. package/dist/protocol/tools.js +98 -42
  49. package/dist/usage.d.ts +2 -2
  50. package/dist/usage.js +2 -2
  51. package/dist/web-search-tool.d.ts +22 -26
  52. package/dist/web-search-tool.js +25 -15
  53. package/package.json +7 -4
@@ -0,0 +1,25 @@
1
+ import { executeCursorImageSave } from "./image-save.js";
2
+ /** Build the classic OpenCode image-save tool with the host's own Zod helper. */
3
+ export function createCursorImageSaveTool(factory) {
4
+ return factory.tool({
5
+ description: "Save an image that Cursor generated during this session to its target path. "
6
+ + "Takes only the id of an already-generated image — it cannot write arbitrary "
7
+ + "files, and it is not a general-purpose file writer. You do not normally call "
8
+ + "this: the Cursor provider issues it after an image is generated.",
9
+ args: {
10
+ image_id: factory.schema.string().describe("Id of the pending Cursor-generated image to save"),
11
+ },
12
+ execute: executeCursorImageSave,
13
+ });
14
+ }
15
+ /** Host-neutral JSON-schema fallback for hosts with no classic helper. */
16
+ export const cursorImageSaveTool = {
17
+ description: "Save an image that Cursor generated during this session to its target path. "
18
+ + "Takes only the id of an already-generated image — it cannot write arbitrary "
19
+ + "files, and it is not a general-purpose file writer. You do not normally call "
20
+ + "this: the Cursor provider issues it after an image is generated.",
21
+ args: {
22
+ image_id: { type: "string", description: "Id of the pending Cursor-generated image to save" },
23
+ },
24
+ execute: executeCursorImageSave,
25
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Commit a staged Cursor image to disk, gated by OpenCode's own permission.
3
+ *
4
+ * Host-neutral on purpose: `./image-save-tool.ts` performs the `tool()` value
5
+ * import from `@opencode-ai/plugin`, which the OpenCode 2.0 entrypoint must not
6
+ * pull in (see the note in `./web-search-tool.ts`). Only that file may import
7
+ * the plugin package; this module is safe on every graph.
8
+ */
9
+ /**
10
+ * Marks a refusal that must reach Cursor as `WriteResult.permission_denied`
11
+ * rather than a generic error — the variant its own executor returns and its
12
+ * agent branches on. Both sides of this string are ours, so matching on it is
13
+ * a contract, not a guess at someone else's message.
14
+ */
15
+ export declare const IMAGE_PERMISSION_DENIED_PREFIX = "CursorImagePermissionDenied:";
16
+ /** The subset of OpenCode's plugin ToolContext this needs. */
17
+ export type ImageSaveToolContext = {
18
+ worktree: string;
19
+ directory: string;
20
+ ask(input: {
21
+ permission: string;
22
+ patterns: string[];
23
+ always: string[];
24
+ metadata: Record<string, unknown>;
25
+ }): Promise<void>;
26
+ };
27
+ export type ImageSaveResult = {
28
+ title: string;
29
+ output: string;
30
+ attachments?: Array<{
31
+ type: "file";
32
+ mime: string;
33
+ url: string;
34
+ filename?: string;
35
+ }>;
36
+ };
37
+ /**
38
+ * Prove the staged target still resolves inside one of the roots it was mapped
39
+ * into. `remapCursorImageWritePath` already chose the location; this re-checks
40
+ * it against the *real* paths, because a symlink planted between mapping and
41
+ * commit could otherwise redirect the write. Mirrors the containment rule the
42
+ * correlated-edit read applies before answering with complete file content.
43
+ */
44
+ export declare function resolveContainedImagePath(target: string, allowedRoots: readonly string[]): {
45
+ path: string;
46
+ relative: string;
47
+ root: string;
48
+ } | {
49
+ error: string;
50
+ };
51
+ /**
52
+ * Execute the commit. `image_id` is the only input: the path and bytes come
53
+ * from this process's staging table, so a caller without a real handle — the
54
+ * model of any provider, replaying a used id, or guessing — writes nothing.
55
+ */
56
+ export declare function executeCursorImageSave(args: {
57
+ image_id?: unknown;
58
+ }, ctx: ImageSaveToolContext): Promise<ImageSaveResult | string>;
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Commit a staged Cursor image to disk, gated by OpenCode's own permission.
3
+ *
4
+ * Host-neutral on purpose: `./image-save-tool.ts` performs the `tool()` value
5
+ * import from `@opencode-ai/plugin`, which the OpenCode 2.0 entrypoint must not
6
+ * pull in (see the note in `./web-search-tool.ts`). Only that file may import
7
+ * the plugin package; this module is safe on every graph.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { trace } from "./debug.js";
12
+ import { takePendingCursorImage } from "./image-staging.js";
13
+ /**
14
+ * Marks a refusal that must reach Cursor as `WriteResult.permission_denied`
15
+ * rather than a generic error — the variant its own executor returns and its
16
+ * agent branches on. Both sides of this string are ours, so matching on it is
17
+ * a contract, not a guess at someone else's message.
18
+ */
19
+ export const IMAGE_PERMISSION_DENIED_PREFIX = "CursorImagePermissionDenied:";
20
+ /**
21
+ * Prove the staged target still resolves inside one of the roots it was mapped
22
+ * into. `remapCursorImageWritePath` already chose the location; this re-checks
23
+ * it against the *real* paths, because a symlink planted between mapping and
24
+ * commit could otherwise redirect the write. Mirrors the containment rule the
25
+ * correlated-edit read applies before answering with complete file content.
26
+ */
27
+ export function resolveContainedImagePath(target, allowedRoots) {
28
+ const roots = allowedRoots.filter(Boolean).map((root) => path.resolve(root));
29
+ if (roots.length === 0)
30
+ return { error: "no writable root is configured" };
31
+ const absolute = path.isAbsolute(target) ? path.resolve(target) : path.resolve(roots[0], target);
32
+ // The file need not exist yet; its closest existing ancestor must still
33
+ // resolve inside an allowed root.
34
+ let probe = absolute;
35
+ let realProbe;
36
+ while (true) {
37
+ try {
38
+ realProbe = fs.realpathSync(probe);
39
+ break;
40
+ }
41
+ catch {
42
+ const parent = path.dirname(probe);
43
+ if (parent === probe)
44
+ break;
45
+ probe = parent;
46
+ }
47
+ }
48
+ if (realProbe === undefined)
49
+ return { error: "target path could not be resolved" };
50
+ const suffix = path.relative(probe, absolute);
51
+ const realTarget = suffix ? path.join(realProbe, suffix) : realProbe;
52
+ for (const root of roots) {
53
+ let realRoot;
54
+ try {
55
+ realRoot = fs.realpathSync(root);
56
+ }
57
+ catch {
58
+ realRoot = root;
59
+ }
60
+ const relative = path.relative(realRoot, realTarget);
61
+ if (relative && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
62
+ // Report the root as the caller supplied it (resolved but not realpathed)
63
+ // so callers can compare it against their own roots — on macOS a temp dir
64
+ // realpaths from /var to /private/var and would never match otherwise.
65
+ return { path: realTarget, relative, root };
66
+ }
67
+ }
68
+ return { error: "target path resolves outside the workspace and project folder" };
69
+ }
70
+ /**
71
+ * Resolve a root the same way the target was resolved. Comparing a realpath
72
+ * target against a lexical root misreads a symlinked project root (macOS
73
+ * /var → /private/var, or a worktree reached through a link) as external.
74
+ */
75
+ function realRoot(root) {
76
+ const resolved = path.resolve(root);
77
+ try {
78
+ return fs.realpathSync(resolved);
79
+ }
80
+ catch {
81
+ return resolved;
82
+ }
83
+ }
84
+ /**
85
+ * Mirror of OpenCode's `containsPath`: a target inside the session directory or
86
+ * worktree is internal. A worktree of `/` is skipped there because it would
87
+ * match every absolute path and silently disable the external gate.
88
+ */
89
+ function isInsideProject(target, worktree, directory) {
90
+ for (const root of [directory, worktree]) {
91
+ if (!root || root === "/")
92
+ continue;
93
+ const relative = path.relative(realRoot(root), target);
94
+ if (relative === "")
95
+ return true;
96
+ if (relative && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Execute the commit. `image_id` is the only input: the path and bytes come
103
+ * from this process's staging table, so a caller without a real handle — the
104
+ * model of any provider, replaying a used id, or guessing — writes nothing.
105
+ */
106
+ export async function executeCursorImageSave(args, ctx) {
107
+ const imageId = typeof args.image_id === "string" ? args.image_id : "";
108
+ if (!imageId)
109
+ return "No image id was provided, so there is nothing to save.";
110
+ const image = takePendingCursorImage(imageId);
111
+ if (!image) {
112
+ // Expired, already committed, or never existed. Identical response for all
113
+ // three: nothing here should confirm whether an id was ever valid.
114
+ return "No pending Cursor image matches that id. It may have already been saved or expired.";
115
+ }
116
+ const workspace = ctx.worktree || ctx.directory;
117
+ const contained = resolveContainedImagePath(image.path, [image.projectDir, workspace].filter((root) => !!root));
118
+ if ("error" in contained) {
119
+ trace(`image save: refused path=${JSON.stringify(image.path)} reason=${contained.error}`);
120
+ return `Refusing to save the generated image: ${contained.error}.`;
121
+ }
122
+ // Mirror what OpenCode's own `write` tool does, in the same order: an
123
+ // external-directory gate for targets outside the project, then the `edit`
124
+ // permission. Cursor writes generated images into the project folder, which
125
+ // is under the host cache and therefore *outside* the worktree — exactly the
126
+ // case `assertExternalDirectory` exists for. Asking only for `edit` would
127
+ // quietly skip a boundary the host enforces for every one of its own writes.
128
+ const editPattern = path.relative(realRoot(workspace), contained.path);
129
+ try {
130
+ if (!isInsideProject(contained.path, workspace, ctx.directory)) {
131
+ const parentDir = path.dirname(contained.path);
132
+ const glob = path.join(parentDir, "*").replaceAll("\\", "/");
133
+ await ctx.ask({
134
+ permission: "external_directory",
135
+ patterns: [glob],
136
+ always: [glob],
137
+ metadata: { filepath: contained.path, parentDir },
138
+ });
139
+ }
140
+ await ctx.ask({
141
+ permission: "edit",
142
+ patterns: [editPattern],
143
+ always: ["*"],
144
+ metadata: {
145
+ filepath: contained.path,
146
+ mime: image.mime,
147
+ bytes: image.data.length,
148
+ source: "cursor-generate-image",
149
+ },
150
+ });
151
+ }
152
+ catch (error) {
153
+ const reason = error instanceof Error ? error.message : String(error);
154
+ trace(`image save: permission refused path=${JSON.stringify(contained.path)} reason=${reason}`);
155
+ throw new Error(`${IMAGE_PERMISSION_DENIED_PREFIX} ${reason}`);
156
+ }
157
+ // Cursor writes generated images into an `assets/` subdirectory that will not
158
+ // exist on a first generation, so the parent chain is created here — after
159
+ // the permission, so a refusal leaves no directories behind.
160
+ const directory = path.dirname(contained.path);
161
+ try {
162
+ fs.mkdirSync(directory, { recursive: true });
163
+ fs.writeFileSync(contained.path, image.data);
164
+ }
165
+ catch (error) {
166
+ // Most often something already occupies a path component (EEXIST/ENOTDIR).
167
+ // Say which path is blocked; the raw errno alone tells the model nothing.
168
+ const reason = error instanceof Error ? error.message : String(error);
169
+ trace(`image save: write failed path=${JSON.stringify(contained.path)} reason=${reason}`);
170
+ throw new Error(`Could not save the generated image to ${contained.path}: ${reason}. `
171
+ + `Check that ${directory} is a writable directory.`);
172
+ }
173
+ trace(`image save: wrote path=${JSON.stringify(contained.path)} ` +
174
+ `bytes=${image.data.length} mime=${image.mime}`);
175
+ // No `attachments`, deliberately. OpenCode gates media-in-tool-result on an
176
+ // allowlist of `model.api.npm` values (@ai-sdk/anthropic, @ai-sdk/openai, …)
177
+ // that no third-party provider can join, so an image attachment is always
178
+ // "extracted to be sent as a separate user message" (message-v2.ts:299-305).
179
+ // That trailing user message makes the next turn stop looking like a tool
180
+ // continuation, so the provider opens a fresh Run and Cursor never receives
181
+ // the write result it is waiting for — observed live: the Run was superseded
182
+ // with pending=1 and a full 1.7 MB rebase was paid. Text only keeps the
183
+ // held-Run contract intact; the file is on disk and Cursor holds the image
184
+ // server-side already.
185
+ return {
186
+ title: editPattern,
187
+ output: `Saved the generated image to ${contained.path} (${image.data.length} bytes).`,
188
+ };
189
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Staging for images Cursor generated server-side.
3
+ *
4
+ * Cursor's GenerateImage runs on its servers and hands the client finished
5
+ * bytes. OpenCode's `write` tool takes a string and additionally BOM-splits,
6
+ * diffs, and runs `Format.file()` on what it writes, so image bytes cannot pass
7
+ * through it — the bytes have to be written by provider code.
8
+ *
9
+ * That write still must raise OpenCode's permission, and `ctx.ask` is only
10
+ * reachable inside a plugin tool's `execute`. So the bytes wait here, keyed by
11
+ * a single-use id, and the tool that commits them takes only that id: no path
12
+ * and no content ever crosses the tool boundary. A tool visible in the catalog
13
+ * therefore cannot be used to write arbitrary bytes anywhere — it can only
14
+ * commit an image this provider already received, or do nothing.
15
+ *
16
+ * Approach adapted from jkalasas/opencode-antigravity-image (MIT), which
17
+ * established writing image bytes from inside a plugin tool rather than through
18
+ * the host's text `write`. No code was copied; the permission gate, the
19
+ * handle-only tool surface, and the containment checks are this provider's.
20
+ */
21
+ export type PendingCursorImage = {
22
+ id: string;
23
+ /** Absolute target, already remapped onto a real location under this host. */
24
+ path: string;
25
+ /** Host project folder the target was mapped into; an allowed write root. */
26
+ projectDir?: string;
27
+ mime: string;
28
+ data: Uint8Array;
29
+ /** OpenCode session that may commit this image. */
30
+ sessionId?: string;
31
+ createdAt: number;
32
+ };
33
+ export type StageCursorImageInput = {
34
+ path: string;
35
+ projectDir?: string;
36
+ mime: string;
37
+ data: Uint8Array;
38
+ sessionId?: string;
39
+ };
40
+ /** Cursor's own edit ceiling is 50 MB; images are far smaller in practice. */
41
+ export declare const MAX_STAGED_IMAGE_BYTES: number;
42
+ /** A staged image is committed within one tool round-trip or it is stale. */
43
+ export declare const STAGED_IMAGE_TTL_MS: number;
44
+ export declare class StagedImageTooLargeError extends Error {
45
+ constructor(bytes: number);
46
+ }
47
+ /** Hold generated bytes and return the handle the commit tool will be given. */
48
+ export declare function stageCursorImage(input: StageCursorImageInput, now?: number): string;
49
+ /**
50
+ * Claim a staged image. Single use: a replayed or guessed id finds nothing, so
51
+ * the commit tool cannot be driven twice or by a caller that never had a real
52
+ * handle.
53
+ */
54
+ export declare function takePendingCursorImage(id: string, now?: number): PendingCursorImage | undefined;
55
+ /** Drop a staged image whose turn ended without committing it. */
56
+ export declare function discardPendingCursorImage(id: string): void;
57
+ /** Test seam. */
58
+ export declare function clearPendingCursorImages(): void;
59
+ export declare function pendingCursorImageCount(): number;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Staging for images Cursor generated server-side.
3
+ *
4
+ * Cursor's GenerateImage runs on its servers and hands the client finished
5
+ * bytes. OpenCode's `write` tool takes a string and additionally BOM-splits,
6
+ * diffs, and runs `Format.file()` on what it writes, so image bytes cannot pass
7
+ * through it — the bytes have to be written by provider code.
8
+ *
9
+ * That write still must raise OpenCode's permission, and `ctx.ask` is only
10
+ * reachable inside a plugin tool's `execute`. So the bytes wait here, keyed by
11
+ * a single-use id, and the tool that commits them takes only that id: no path
12
+ * and no content ever crosses the tool boundary. A tool visible in the catalog
13
+ * therefore cannot be used to write arbitrary bytes anywhere — it can only
14
+ * commit an image this provider already received, or do nothing.
15
+ *
16
+ * Approach adapted from jkalasas/opencode-antigravity-image (MIT), which
17
+ * established writing image bytes from inside a plugin tool rather than through
18
+ * the host's text `write`. No code was copied; the permission gate, the
19
+ * handle-only tool surface, and the containment checks are this provider's.
20
+ */
21
+ import { randomUUID } from "node:crypto";
22
+ /** Cursor's own edit ceiling is 50 MB; images are far smaller in practice. */
23
+ export const MAX_STAGED_IMAGE_BYTES = 50 * 1024 * 1024;
24
+ /** A staged image is committed within one tool round-trip or it is stale. */
25
+ export const STAGED_IMAGE_TTL_MS = 10 * 60_000;
26
+ /** Bounds memory if a turn generates several images and none are committed. */
27
+ const MAX_PENDING = 8;
28
+ const pending = new Map();
29
+ export class StagedImageTooLargeError extends Error {
30
+ constructor(bytes) {
31
+ super(`Generated image is ${bytes} bytes, above the ${MAX_STAGED_IMAGE_BYTES} byte limit`);
32
+ this.name = "StagedImageTooLargeError";
33
+ }
34
+ }
35
+ function prune(now) {
36
+ for (const [id, image] of pending) {
37
+ if (now - image.createdAt > STAGED_IMAGE_TTL_MS)
38
+ pending.delete(id);
39
+ }
40
+ // Oldest-first eviction: Map preserves insertion order and ids are only ever
41
+ // appended, so the first entries are the oldest.
42
+ while (pending.size >= MAX_PENDING) {
43
+ const oldest = pending.keys().next().value;
44
+ if (oldest === undefined)
45
+ break;
46
+ pending.delete(oldest);
47
+ }
48
+ }
49
+ /** Hold generated bytes and return the handle the commit tool will be given. */
50
+ export function stageCursorImage(input, now = Date.now()) {
51
+ if (input.data.length > MAX_STAGED_IMAGE_BYTES) {
52
+ throw new StagedImageTooLargeError(input.data.length);
53
+ }
54
+ prune(now);
55
+ const id = `cursor-image-${randomUUID()}`;
56
+ pending.set(id, {
57
+ id,
58
+ path: input.path,
59
+ projectDir: input.projectDir,
60
+ mime: input.mime,
61
+ data: input.data,
62
+ sessionId: input.sessionId,
63
+ createdAt: now,
64
+ });
65
+ return id;
66
+ }
67
+ /**
68
+ * Claim a staged image. Single use: a replayed or guessed id finds nothing, so
69
+ * the commit tool cannot be driven twice or by a caller that never had a real
70
+ * handle.
71
+ */
72
+ export function takePendingCursorImage(id, now = Date.now()) {
73
+ const image = pending.get(id);
74
+ if (!image)
75
+ return undefined;
76
+ pending.delete(id);
77
+ if (now - image.createdAt > STAGED_IMAGE_TTL_MS)
78
+ return undefined;
79
+ return image;
80
+ }
81
+ /** Drop a staged image whose turn ended without committing it. */
82
+ export function discardPendingCursorImage(id) {
83
+ pending.delete(id);
84
+ }
85
+ /** Test seam. */
86
+ export function clearPendingCursorImages() {
87
+ pending.clear();
88
+ }
89
+ export function pendingCursorImageCount() {
90
+ return pending.size;
91
+ }
package/dist/index.d.ts CHANGED
@@ -26,7 +26,7 @@ export type CreateCursorOptions = {
26
26
  /**
27
27
  * Host cache root for Cursor project metadata + model/version caches.
28
28
  * Prefer the host's Path.cache (Effect v2) when available; otherwise the
29
- * provider resolves OpenCode / MiMo / Kilo XDG cache dirs automatically.
29
+ * provider resolves the native OpenCode cache dir, or an injected host path bridge.
30
30
  */
31
31
  cacheDir?: string;
32
32
  /** Held-stream policy. Defaults: heartbeat 5s, semantic idle 120s, tool inactivity 10m. */
@@ -135,10 +135,11 @@ export declare function resolveTurnToolState(input: {
135
135
  incomingTools: OpencodeToolDef[];
136
136
  toolChoice?: LanguageModelV3CallOptions["toolChoice"];
137
137
  isCompaction: boolean;
138
- }): {
138
+ abortSignal?: AbortSignal;
139
+ }): Promise<{
139
140
  advertisedTools: OpencodeToolDef[];
140
141
  allowTools: boolean;
141
- };
142
+ }>;
142
143
  export declare function resolveTurnConversationReset(input: {
143
144
  sessionKey?: string;
144
145
  isCompaction: boolean;