cursor-opencode-provider 0.2.8 → 0.2.9
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 +15 -3
- package/dist/context/agents.d.ts +5 -1
- package/dist/context/agents.js +90 -30
- package/dist/context/build.js +57 -8
- package/dist/context/paths.d.ts +10 -0
- package/dist/context/paths.js +24 -0
- package/dist/context/plugins.js +16 -12
- package/dist/context/rules.js +26 -23
- package/dist/context/skills.js +8 -3
- package/dist/language-model.js +84 -2
- package/dist/protocol/messages.js +20 -1
- package/dist/protocol/tool-call-bridge.js +22 -2
- package/dist/protocol/tools.d.ts +49 -0
- package/dist/protocol/tools.js +268 -13
- package/dist/session.d.ts +3 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ OpenCode driving a Cursor-routed Grok model through this provider:
|
|
|
18
18
|
- **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
|
|
19
19
|
- **Model discovery** — fetches available models from Cursor's API and caches them locally
|
|
20
20
|
- **Streaming** — bidirectional Connect-RPC Runs with stale-session rotation, health checks, semantic/read-idle deadlines, bounded replay-safe recovery, and activity-aware held tool continuations
|
|
21
|
-
- **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts, including native subagent
|
|
21
|
+
- **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts, including catalog-aware native subagent execution: exact advertised custom agents win, `unspecified` / `generalPurpose` select `general`, read-oriented `bugbot` / `security-review` select `explore`, and `cursor-guide` selects enabled Kilo `scout` before `explore`; MiMo `actor` is used instead of its work-item `task` tool when advertised. The bridge also covers the Pi read/bash/edit/write/grep/find/ls request/result range, enforces the exact current OpenCode tool catalog, mirrors finalized display-only todo/plan state, and strips OpenCode's `read` XML envelope before returning content to Cursor.
|
|
22
22
|
- **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
|
|
23
23
|
|
|
24
24
|
## Requirements
|
|
@@ -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.9"`.
|
|
51
51
|
|
|
52
52
|
### From a local clone
|
|
53
53
|
|
|
@@ -238,6 +238,18 @@ The provider adds OpenCode-specific system guidance to normal tool-capable conve
|
|
|
238
238
|
|
|
239
239
|
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).
|
|
240
240
|
|
|
241
|
+
### Native subagent routing
|
|
242
|
+
|
|
243
|
+
The provider reads the permission-filtered subagent catalog from the current host `task` or `actor` definition and advertises those recipients to Cursor as custom subagents. Exact configured names are preserved; unknown future Cursor subtype strings fall back to `general`. If a complete catalog omits every compatible recipient, the request fails on Cursor's typed subagent channel instead of selecting an arbitrary specialist.
|
|
244
|
+
|
|
245
|
+
| Host | Default recipients | Optional recipients |
|
|
246
|
+
|------|--------------------|---------------------|
|
|
247
|
+
| OpenCode | `general`, `explore` | Add `agent/*.md` or `agents/*.md` with `mode: subagent` or `all` |
|
|
248
|
+
| Kilo | `general`, `explore` | Enable `scout` with `KILO_EXPERIMENTAL_SCOUT=true` (or `KILO_EXPERIMENTAL=true`); add `.kilo/agent/*.md` custom agents with `mode: subagent` or `all` |
|
|
249
|
+
| MiMo | `general`, `explore` | Add `.mimocode/agent/*.md` with `mode: subagent` |
|
|
250
|
+
|
|
251
|
+
Kilo `scout` is reserved for external documentation, dependency repositories, and upstream source. Local workspace discovery continues to use `explore`. Primary modes and hidden/internal agents are never selected unless the host explicitly exposes them as spawnable.
|
|
252
|
+
|
|
241
253
|
## Package exports
|
|
242
254
|
|
|
243
255
|
| Import path | Export |
|
|
@@ -284,4 +296,4 @@ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). S
|
|
|
284
296
|
|
|
285
297
|
## License
|
|
286
298
|
|
|
287
|
-
MIT
|
|
299
|
+
MIT
|
package/dist/context/agents.d.ts
CHANGED
|
@@ -4,5 +4,9 @@ export type CollectedAgent = {
|
|
|
4
4
|
description: string;
|
|
5
5
|
prompt: string;
|
|
6
6
|
};
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Custom agents from the active OpenCode-compatible config roots. OCP may
|
|
9
|
+
* install a host-neutral bridge before loading this module; without one this
|
|
10
|
+
* remains the direct OpenCode discovery path.
|
|
11
|
+
*/
|
|
8
12
|
export declare function collectAgents(workspaceRoot: string): Promise<CollectedAgent[]>;
|
package/dist/context/agents.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { readdir, readFile, stat } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
|
|
4
4
|
async function exists(p) {
|
|
5
5
|
try {
|
|
6
6
|
await stat(p);
|
|
@@ -36,41 +36,101 @@ function parseAgentMarkdown(raw, fallbackName) {
|
|
|
36
36
|
}
|
|
37
37
|
return { name, description, prompt: body.slice(0, 20_000) };
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
function uniquePaths(paths) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
return paths.filter((value) => {
|
|
42
|
+
const normalized = path.resolve(value);
|
|
43
|
+
if (seen.has(normalized))
|
|
44
|
+
return false;
|
|
45
|
+
seen.add(normalized);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Read one host config root's `agent/` and `agents/` trees. Host loaders use
|
|
51
|
+
* both spellings and recurse through nested markdown files. Directory symlinks
|
|
52
|
+
* are followed with realpath cycle detection, matching host behavior without
|
|
53
|
+
* allowing a symlink loop to hang context construction.
|
|
54
|
+
*/
|
|
55
|
+
async function scanAgentRoot(root, out) {
|
|
56
|
+
if (!(await exists(root)))
|
|
47
57
|
return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
continue;
|
|
52
|
-
const full = path.join(dir, name);
|
|
58
|
+
const visitedDirs = new Set();
|
|
59
|
+
const scanDir = async (dir, relativePrefix) => {
|
|
60
|
+
let canonical;
|
|
53
61
|
try {
|
|
54
|
-
|
|
55
|
-
const parsed = parseAgentMarkdown(raw, name.replace(/\.md$/, ""));
|
|
56
|
-
if (!out.has(parsed.name)) {
|
|
57
|
-
out.set(parsed.name, {
|
|
58
|
-
fullPath: path.resolve(full),
|
|
59
|
-
name: parsed.name,
|
|
60
|
-
description: parsed.description,
|
|
61
|
-
prompt: parsed.prompt,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
62
|
+
canonical = await realpath(dir);
|
|
64
63
|
}
|
|
65
64
|
catch {
|
|
66
|
-
|
|
65
|
+
return;
|
|
67
66
|
}
|
|
68
|
-
|
|
67
|
+
if (visitedDirs.has(canonical))
|
|
68
|
+
return;
|
|
69
|
+
visitedDirs.add(canonical);
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
73
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const full = path.join(dir, entry.name);
|
|
80
|
+
const relative = path.join(relativePrefix, entry.name);
|
|
81
|
+
let isDirectory = entry.isDirectory();
|
|
82
|
+
let isFile = entry.isFile();
|
|
83
|
+
if (entry.isSymbolicLink()) {
|
|
84
|
+
try {
|
|
85
|
+
const target = await stat(full);
|
|
86
|
+
isDirectory = target.isDirectory();
|
|
87
|
+
isFile = target.isFile();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (isDirectory) {
|
|
94
|
+
await scanDir(full, relative);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!isFile || !entry.name.endsWith(".md"))
|
|
98
|
+
continue;
|
|
99
|
+
try {
|
|
100
|
+
const raw = await readFile(full, "utf-8");
|
|
101
|
+
const fallbackName = relative.replace(/\\/g, "/").replace(/\.md$/, "");
|
|
102
|
+
const parsed = parseAgentMarkdown(raw, fallbackName);
|
|
103
|
+
if (!out.has(parsed.name)) {
|
|
104
|
+
out.set(parsed.name, {
|
|
105
|
+
fullPath: path.resolve(full),
|
|
106
|
+
name: parsed.name,
|
|
107
|
+
description: parsed.description,
|
|
108
|
+
prompt: parsed.prompt,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* skip unreadable or malformed files without failing context build */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
// Scan singular before plural, then let root ordering establish host
|
|
118
|
+
// precedence. This is deterministic and mirrors the hosts' brace glob.
|
|
119
|
+
await scanDir(path.join(root, "agent"), "agent");
|
|
120
|
+
await scanDir(path.join(root, "agents"), "agents");
|
|
69
121
|
}
|
|
70
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Custom agents from the active OpenCode-compatible config roots. OCP may
|
|
124
|
+
* install a host-neutral bridge before loading this module; without one this
|
|
125
|
+
* remains the direct OpenCode discovery path.
|
|
126
|
+
*/
|
|
71
127
|
export async function collectAgents(workspaceRoot) {
|
|
72
128
|
const out = new Map();
|
|
73
|
-
|
|
74
|
-
|
|
129
|
+
const roots = uniquePaths([
|
|
130
|
+
...opencodeProjectConfigDirs(workspaceRoot),
|
|
131
|
+
...opencodeGlobalConfigDirs(),
|
|
132
|
+
]);
|
|
133
|
+
for (const root of roots)
|
|
134
|
+
await scanAgentRoot(root, out);
|
|
75
135
|
return [...out.values()];
|
|
76
136
|
}
|
package/dist/context/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { toolsToDescriptors, toolsToMcpDescriptors } from "../protocol/tools.js";
|
|
2
|
+
import { extractHostSubagentCatalog, toolsToDescriptors, toolsToMcpDescriptors, } from "../protocol/tools.js";
|
|
3
3
|
import { collectRules } from "./rules.js";
|
|
4
4
|
import { collectSkills } from "./skills.js";
|
|
5
5
|
import { collectAgents } from "./agents.js";
|
|
@@ -9,6 +9,40 @@ import { collectProjectLayout } from "./layout.js";
|
|
|
9
9
|
import { buildEnv } from "./env.js";
|
|
10
10
|
import { ensureOpencodeProjectDir } from "./paths.js";
|
|
11
11
|
import { traceRequestContextPaths } from "../debug.js";
|
|
12
|
+
const DEFAULT_HOST_SUBAGENTS = [
|
|
13
|
+
{
|
|
14
|
+
name: "general",
|
|
15
|
+
description: "General-purpose agent for complex research and multi-step tasks.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "explore",
|
|
19
|
+
description: "Read-only agent for searching and understanding the local codebase.",
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
/**
|
|
23
|
+
* Convert the raw host executor catalog into the exact subagent list advertised
|
|
24
|
+
* to Cursor. `hostSubagents.complete` answers a narrow question: did the host's
|
|
25
|
+
* task/actor tool itself expose an exhaustive recipient list (schema enum or
|
|
26
|
+
* catalog marker)? OpenCode's task tool intentionally uses a plain string, so
|
|
27
|
+
* that raw flag is false even though we can still advertise a complete Cursor
|
|
28
|
+
* catalog by adding built-in defaults and discovered agent files.
|
|
29
|
+
*
|
|
30
|
+
* `custom_subagents_info_complete` must describe the final advertised list, not
|
|
31
|
+
* the raw extraction source. If there is no executor, the empty list is
|
|
32
|
+
* complete. If the host catalog is complete, use it verbatim. Otherwise we make
|
|
33
|
+
* the advertised set complete by augmenting with default host agents and all
|
|
34
|
+
* locally discovered agents.
|
|
35
|
+
*/
|
|
36
|
+
function buildAdvertisedSubagentCatalog(hostSubagents, discoveredAgents) {
|
|
37
|
+
if (!hostSubagents.executor)
|
|
38
|
+
return { agents: [], complete: true };
|
|
39
|
+
if (hostSubagents.complete)
|
|
40
|
+
return { agents: hostSubagents.agents, complete: true };
|
|
41
|
+
return {
|
|
42
|
+
agents: [...DEFAULT_HOST_SUBAGENTS, ...hostSubagents.agents, ...discoveredAgents],
|
|
43
|
+
complete: true,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
12
46
|
/**
|
|
13
47
|
* Full RequestContext payload for live UMA + exec #10 reply.
|
|
14
48
|
* Sourced from OpenCode discovery (and .claude/.agents skill fallbacks).
|
|
@@ -30,6 +64,26 @@ export async function buildRequestContext(input) {
|
|
|
30
64
|
const flat = toolsToDescriptors(tools, providerIdentifier, mcpServerNames);
|
|
31
65
|
const nested = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames);
|
|
32
66
|
const projectDir = ensureOpencodeProjectDir(workspaceRoot);
|
|
67
|
+
const hostSubagents = extractHostSubagentCatalog(tools);
|
|
68
|
+
const advertisedSubagents = buildAdvertisedSubagentCatalog(hostSubagents, agents);
|
|
69
|
+
const discoveredByName = new Map(agents.map((agent) => [agent.name, agent]));
|
|
70
|
+
const advertisedByName = new Map();
|
|
71
|
+
for (const agent of advertisedSubagents.agents) {
|
|
72
|
+
if (!advertisedByName.has(agent.name))
|
|
73
|
+
advertisedByName.set(agent.name, agent);
|
|
74
|
+
}
|
|
75
|
+
const customSubagents = [...advertisedByName.values()].map((agent) => {
|
|
76
|
+
const discovered = discoveredByName.get(agent.name);
|
|
77
|
+
return {
|
|
78
|
+
full_path: discovered?.fullPath ?? "",
|
|
79
|
+
name: agent.name,
|
|
80
|
+
description: discovered?.description || agent.description || "Host-configured subagent.",
|
|
81
|
+
// The host applies the real configured prompt when Task/Actor executes.
|
|
82
|
+
// Cursor only needs enough context to select the recipient intentionally.
|
|
83
|
+
prompt: discovered?.prompt ||
|
|
84
|
+
`Delegate to the host-configured ${agent.name} subagent; its host instructions and tools apply.`,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
33
87
|
const ctx = {
|
|
34
88
|
env: buildEnv(workspaceRoot),
|
|
35
89
|
tools: flat,
|
|
@@ -45,12 +99,7 @@ export async function buildRequestContext(input) {
|
|
|
45
99
|
content: s.content,
|
|
46
100
|
description: s.description,
|
|
47
101
|
})),
|
|
48
|
-
custom_subagents:
|
|
49
|
-
full_path: a.fullPath,
|
|
50
|
-
name: a.name,
|
|
51
|
-
description: a.description,
|
|
52
|
-
prompt: a.prompt,
|
|
53
|
-
})),
|
|
102
|
+
custom_subagents: customSubagents,
|
|
54
103
|
mcp_file_system_options: {
|
|
55
104
|
enabled: true,
|
|
56
105
|
// Cursor metadata root (mcps / agent-tools), not the git workspace.
|
|
@@ -74,7 +123,7 @@ export async function buildRequestContext(input) {
|
|
|
74
123
|
git_repo_info_complete: true,
|
|
75
124
|
git_status_info_complete: true,
|
|
76
125
|
agent_skills_info_complete: true,
|
|
77
|
-
custom_subagents_info_complete:
|
|
126
|
+
custom_subagents_info_complete: advertisedSubagents.complete,
|
|
78
127
|
mcp_file_system_info_complete: true,
|
|
79
128
|
mcp_info_complete: true,
|
|
80
129
|
};
|
package/dist/context/paths.d.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
export type HostPathEnv = NodeJS.ProcessEnv;
|
|
2
|
+
/** Host-neutral bridge installed by OCP before an unchanged provider is loaded. */
|
|
3
|
+
export declare const OPENCODE_PATH_BRIDGE: unique symbol;
|
|
4
|
+
export type OpenCodePathBridge = {
|
|
5
|
+
projectConfigDirs: (workspaceRoot: string) => string[];
|
|
6
|
+
globalConfigDirs: () => string[];
|
|
7
|
+
configFileNames?: string[];
|
|
8
|
+
};
|
|
9
|
+
export declare function opencodeProjectConfigDirs(workspaceRoot: string): string[];
|
|
10
|
+
export declare function opencodeGlobalConfigDirs(): string[];
|
|
11
|
+
export declare function opencodeConfigFileNames(): string[];
|
|
2
12
|
type CompatDetectResult = {
|
|
3
13
|
id: string;
|
|
4
14
|
supported: boolean;
|
package/dist/context/paths.js
CHANGED
|
@@ -4,6 +4,30 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { trace } from "../debug.js";
|
|
7
|
+
/** Host-neutral bridge installed by OCP before an unchanged provider is loaded. */
|
|
8
|
+
export const OPENCODE_PATH_BRIDGE = Symbol.for("opencode.compat.path-bridge");
|
|
9
|
+
function pathBridge() {
|
|
10
|
+
const value = globalThis[OPENCODE_PATH_BRIDGE];
|
|
11
|
+
if (!value || typeof value !== "object")
|
|
12
|
+
return undefined;
|
|
13
|
+
const bridge = value;
|
|
14
|
+
return typeof bridge.projectConfigDirs === "function" && typeof bridge.globalConfigDirs === "function"
|
|
15
|
+
? bridge
|
|
16
|
+
: undefined;
|
|
17
|
+
}
|
|
18
|
+
export function opencodeProjectConfigDirs(workspaceRoot) {
|
|
19
|
+
return pathBridge()?.projectConfigDirs(path.resolve(workspaceRoot)) ?? [
|
|
20
|
+
path.join(path.resolve(workspaceRoot), ".opencode"),
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
export function opencodeGlobalConfigDirs() {
|
|
24
|
+
return pathBridge()?.globalConfigDirs() ?? [opencodeGlobalConfigDir()];
|
|
25
|
+
}
|
|
26
|
+
export function opencodeConfigFileNames() {
|
|
27
|
+
return pathBridge()?.configFileNames?.length
|
|
28
|
+
? [...pathBridge().configFileNames]
|
|
29
|
+
: ["opencode.json", "opencode.jsonc"];
|
|
30
|
+
}
|
|
7
31
|
/** Explicit host cache root (e.g. Effect v2 `Path.cache`, or `createCursor({ cacheDir })`). */
|
|
8
32
|
let hostCacheDirOverride;
|
|
9
33
|
function resolveHome(env = process.env) {
|
package/dist/context/plugins.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
|
|
4
4
|
async function listLocalPlugins(dir) {
|
|
5
5
|
try {
|
|
6
6
|
await stat(dir);
|
|
@@ -34,17 +34,21 @@ export async function collectPlugins(workspaceRoot, config) {
|
|
|
34
34
|
seen.add(id);
|
|
35
35
|
out.push({ id, source: "npm" });
|
|
36
36
|
}
|
|
37
|
-
for (const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
for (const configDir of opencodeProjectConfigDirs(workspaceRoot)) {
|
|
38
|
+
for (const p of await listLocalPlugins(path.join(configDir, "plugins"))) {
|
|
39
|
+
if (seen.has(p.id))
|
|
40
|
+
continue;
|
|
41
|
+
seen.add(p.id);
|
|
42
|
+
out.push(p);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const configDir of opencodeGlobalConfigDirs()) {
|
|
46
|
+
for (const p of await listLocalPlugins(path.join(configDir, "plugins"))) {
|
|
47
|
+
if (seen.has(p.id))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(p.id);
|
|
50
|
+
out.push(p);
|
|
51
|
+
}
|
|
48
52
|
}
|
|
49
53
|
return out;
|
|
50
54
|
}
|
package/dist/context/rules.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { opencodeConfigFileNames, opencodeGlobalConfigDirs, opencodeProjectConfigDirs, resolveHomeRelative } from "./paths.js";
|
|
5
5
|
async function exists(file) {
|
|
6
6
|
try {
|
|
7
7
|
await stat(file);
|
|
@@ -12,7 +12,7 @@ async function exists(file) {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
async function readJsonConfig(dir) {
|
|
15
|
-
for (const name of
|
|
15
|
+
for (const name of opencodeConfigFileNames()) {
|
|
16
16
|
const file = path.join(dir, name);
|
|
17
17
|
if (!(await exists(file)))
|
|
18
18
|
continue;
|
|
@@ -151,29 +151,30 @@ export async function fetchRemoteInstruction(url, timeoutMs = 5000) {
|
|
|
151
151
|
clearTimeout(timer);
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
|
|
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
|
-
}
|
|
166
|
-
const projectConfig = await readJsonConfig(workspaceRoot);
|
|
154
|
+
function mergeConfig(base, overlay) {
|
|
167
155
|
return {
|
|
168
|
-
...
|
|
169
|
-
...
|
|
170
|
-
instructions: [...(
|
|
171
|
-
plugin: [...new Set([...(
|
|
172
|
-
plugins: [...new Set([...(
|
|
173
|
-
mcp: { ...(
|
|
174
|
-
permission:
|
|
156
|
+
...base,
|
|
157
|
+
...overlay,
|
|
158
|
+
instructions: [...(base.instructions ?? []), ...(overlay.instructions ?? [])],
|
|
159
|
+
plugin: [...new Set([...(base.plugin ?? []), ...(overlay.plugin ?? [])])],
|
|
160
|
+
plugins: [...new Set([...(base.plugins ?? []), ...(overlay.plugins ?? [])])],
|
|
161
|
+
mcp: { ...(base.mcp ?? {}), ...(overlay.mcp ?? {}) },
|
|
162
|
+
permission: overlay.permission ?? base.permission,
|
|
175
163
|
};
|
|
176
164
|
}
|
|
165
|
+
export async function loadMergedConfig(workspaceRoot) {
|
|
166
|
+
const globalConfig = await readJsonConfig(opencodeGlobalConfigDirs()[0] ?? "");
|
|
167
|
+
if (isProjectConfigDisabled())
|
|
168
|
+
return mergeConfig({}, globalConfig);
|
|
169
|
+
// The bridge supplies native project config roots for an unchanged plugin:
|
|
170
|
+
// .opencode on OpenCode, .mimocode on MiMo, and .kilo/.kilocode on Kilo.
|
|
171
|
+
// Later roots have higher precedence, matching the host's native ordering.
|
|
172
|
+
let projectConfig = await readJsonConfig(workspaceRoot);
|
|
173
|
+
for (const configDir of opencodeProjectConfigDirs(workspaceRoot)) {
|
|
174
|
+
projectConfig = mergeConfig(projectConfig, await readJsonConfig(configDir));
|
|
175
|
+
}
|
|
176
|
+
return mergeConfig(globalConfig, projectConfig);
|
|
177
|
+
}
|
|
177
178
|
/**
|
|
178
179
|
* Collect OpenCode instruction files.
|
|
179
180
|
*/
|
|
@@ -205,7 +206,9 @@ export async function collectRules(workspaceRoot) {
|
|
|
205
206
|
}
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
|
-
|
|
209
|
+
for (const globalDir of opencodeGlobalConfigDirs()) {
|
|
210
|
+
await add(path.join(globalDir, "AGENTS.md"));
|
|
211
|
+
}
|
|
209
212
|
await add(path.join(homedir(), ".claude", "CLAUDE.md"));
|
|
210
213
|
for (const raw of config.instructions ?? []) {
|
|
211
214
|
if (raw.startsWith("http://") || raw.startsWith("https://")) {
|
package/dist/context/skills.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { opencodeGlobalConfigDirs, opencodeProjectConfigDirs } from "./paths.js";
|
|
5
5
|
async function exists(p) {
|
|
6
6
|
try {
|
|
7
7
|
await stat(p);
|
|
@@ -94,8 +94,13 @@ async function walkAncestorsFor(dir, rel, stop, out) {
|
|
|
94
94
|
export async function collectSkills(workspaceRoot, worktree) {
|
|
95
95
|
const out = new Map();
|
|
96
96
|
const home = homedir();
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
for (const projectDir of opencodeProjectConfigDirs(workspaceRoot)) {
|
|
98
|
+
const relative = path.relative(workspaceRoot, projectDir);
|
|
99
|
+
await walkAncestorsFor(workspaceRoot, path.join(relative, "skills"), worktree, out);
|
|
100
|
+
}
|
|
101
|
+
for (const globalDir of opencodeGlobalConfigDirs()) {
|
|
102
|
+
await scanSkillsRoot(path.join(globalDir, "skills"), out);
|
|
103
|
+
}
|
|
99
104
|
await walkAncestorsFor(workspaceRoot, path.join(".claude", "skills"), worktree, out);
|
|
100
105
|
await scanSkillsRoot(path.join(home, ".claude", "skills"), out);
|
|
101
106
|
await walkAncestorsFor(workspaceRoot, path.join(".agents", "skills"), worktree, out);
|
package/dist/language-model.js
CHANGED
|
@@ -6,7 +6,7 @@ import { trace, traceRequestContextPaths } from "./debug.js";
|
|
|
6
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
7
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
8
8
|
import { decodeMessage } from "./protocol/messages.js";
|
|
9
|
-
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, resolveCustomWebToolAlias, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
9
|
+
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, buildReadRejectionMessages, classifyMissingReadTarget, resolveReadTargetPath, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, buildCustomWebToolAliases, extractHostSubagentCatalog, resolveCustomWebToolAlias, remapNativeSubagentForCatalog, CUSTOM_WEBFETCH_TOOL, CUSTOM_WEBSEARCH_TOOL, } from "./protocol/tools.js";
|
|
10
10
|
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
11
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
12
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
@@ -463,6 +463,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
463
463
|
trace(`web tool alias: ${alias} -> ${original}`);
|
|
464
464
|
}
|
|
465
465
|
const allowTools = toolState.allowTools;
|
|
466
|
+
const discoveredSubagentCatalog = extractHostSubagentCatalog(cursorTools);
|
|
466
467
|
const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
|
|
467
468
|
const recovery = startOptions?.recovery;
|
|
468
469
|
const resuming = recovery?.kind === "resume";
|
|
@@ -526,6 +527,23 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
526
527
|
headers: options.headers,
|
|
527
528
|
});
|
|
528
529
|
const requestContext = await buildRequestContext({ workspaceRoot, tools: cursorTools });
|
|
530
|
+
const contextSubagents = Array.isArray(requestContext.custom_subagents)
|
|
531
|
+
? requestContext.custom_subagents
|
|
532
|
+
.map((agent) => agent && typeof agent === "object" && typeof agent.name === "string"
|
|
533
|
+
? {
|
|
534
|
+
name: agent.name,
|
|
535
|
+
description: typeof agent.description === "string"
|
|
536
|
+
? agent.description
|
|
537
|
+
: undefined,
|
|
538
|
+
}
|
|
539
|
+
: undefined)
|
|
540
|
+
.filter((agent) => !!agent)
|
|
541
|
+
: [];
|
|
542
|
+
const subagentCatalog = {
|
|
543
|
+
...discoveredSubagentCatalog,
|
|
544
|
+
agents: [...new Map([...discoveredSubagentCatalog.agents, ...contextSubagents]
|
|
545
|
+
.map((agent) => [agent.name, agent])).values()],
|
|
546
|
+
};
|
|
529
547
|
// Resolve descriptors once from the merged OpenCode config so MCP identity is
|
|
530
548
|
// consistent across AgentRunRequest and both request_context reply paths.
|
|
531
549
|
const toolDescriptors = Array.isArray(requestContext.tools)
|
|
@@ -603,6 +621,7 @@ async function startSession(modelId, token, callOptions, options, startOptions)
|
|
|
603
621
|
blobs: new Map(),
|
|
604
622
|
toolDescriptors,
|
|
605
623
|
toolAliases: webToolAliases.aliases,
|
|
624
|
+
subagentCatalog,
|
|
606
625
|
requestContext,
|
|
607
626
|
allowTools,
|
|
608
627
|
usageEstimate,
|
|
@@ -961,6 +980,49 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
961
980
|
return true;
|
|
962
981
|
}
|
|
963
982
|
};
|
|
983
|
+
/**
|
|
984
|
+
* A read of a path that does not exist would otherwise reach OpenCode as a
|
|
985
|
+
* real tool call and raise a permission prompt for a file the user never had.
|
|
986
|
+
* Cursor's own LocalReadExecutor instead resolves the target against the
|
|
987
|
+
* workspace, stats it, and returns a typed ReadResult (file_not_found /
|
|
988
|
+
* invalid_file) before execution. Mirror that here: reply on the held-open
|
|
989
|
+
* exec channel with the exact typed case so the model receives a structured
|
|
990
|
+
* observation and the host surfaces no prompt.
|
|
991
|
+
*
|
|
992
|
+
* Read-only by construction — write/edit targets are never checked, so
|
|
993
|
+
* legitimate file creation (path resolves, file absent) is never denied. Runs
|
|
994
|
+
* after recoverMissingEditRead, which has already converted the new-file edit
|
|
995
|
+
* handshake read into an empty-file success. EACCES/EPERM and other stat
|
|
996
|
+
* errors fall through to OpenCode so a genuine permission decision stands.
|
|
997
|
+
*/
|
|
998
|
+
const rejectMissingReadTarget = (parsed) => {
|
|
999
|
+
if (parsed.toolName !== "read")
|
|
1000
|
+
return false;
|
|
1001
|
+
const requested = typeof parsed.args.filePath === "string" ? parsed.args.filePath : "";
|
|
1002
|
+
if (!requested)
|
|
1003
|
+
return false;
|
|
1004
|
+
const workspaceRoot = workspaceRootFromRequestContext(session.requestContext);
|
|
1005
|
+
const absolutePath = resolveReadTargetPath(requested, workspaceRoot);
|
|
1006
|
+
const readResult = classifyMissingReadTarget(absolutePath);
|
|
1007
|
+
if (!readResult)
|
|
1008
|
+
return false;
|
|
1009
|
+
try {
|
|
1010
|
+
for (const frame of buildReadRejectionMessages(parsed.id, readResult)) {
|
|
1011
|
+
session.stream.write(frame);
|
|
1012
|
+
}
|
|
1013
|
+
const kind = Object.keys(readResult)[0];
|
|
1014
|
+
trace(`exec: rejected missing read target id=${parsed.id} kind=${kind} ` +
|
|
1015
|
+
`path=${JSON.stringify(absolutePath)}`);
|
|
1016
|
+
return true;
|
|
1017
|
+
}
|
|
1018
|
+
catch (e) {
|
|
1019
|
+
const error = new Error(`Failed to reject Cursor read of a missing path: ${e.message}`);
|
|
1020
|
+
trace(`exec: missing read rejection FAILED ${error.message}`);
|
|
1021
|
+
safeError(error);
|
|
1022
|
+
sessionManager.close(session);
|
|
1023
|
+
return true;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
964
1026
|
/** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
|
|
965
1027
|
const closeOpenSpans = () => {
|
|
966
1028
|
for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
|
|
@@ -1304,6 +1366,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1304
1366
|
trace(`web tool alias resolved: ${parsed.toolName} -> ${executableToolName}`);
|
|
1305
1367
|
parsed.toolName = executableToolName;
|
|
1306
1368
|
}
|
|
1369
|
+
remapNativeSubagentForCatalog(parsed, advertisedToolNameSet, session.subagentCatalog);
|
|
1307
1370
|
}
|
|
1308
1371
|
const displayCallId = extractExecDisplayCallId(esm);
|
|
1309
1372
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
@@ -1344,6 +1407,11 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
1344
1407
|
}
|
|
1345
1408
|
if (recoverMissingEditRead(parsed, displayCallId))
|
|
1346
1409
|
continue;
|
|
1410
|
+
if (rejectMissingReadTarget(parsed)) {
|
|
1411
|
+
if (displayCallId)
|
|
1412
|
+
session.displayToolCalls.delete(displayCallId);
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1347
1415
|
if (displayCallId) {
|
|
1348
1416
|
session.displayToolCalls.delete(displayCallId);
|
|
1349
1417
|
trace(`exec: claimed display callId=${displayCallId}`);
|
|
@@ -1550,6 +1618,7 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1550
1618
|
if (names.size === 0)
|
|
1551
1619
|
return undefined;
|
|
1552
1620
|
const instructions = [];
|
|
1621
|
+
const subagents = extractHostSubagentCatalog(tools);
|
|
1553
1622
|
if (names.has("question")) {
|
|
1554
1623
|
instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
|
|
1555
1624
|
}
|
|
@@ -1568,6 +1637,17 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1568
1637
|
if (names.has(CUSTOM_WEBFETCH_TOOL)) {
|
|
1569
1638
|
instructions.push(`- To fetch a known URL, call \`${CUSTOM_WEBFETCH_TOOL}\`; do not use Cursor's native WebFetch interaction.`);
|
|
1570
1639
|
}
|
|
1640
|
+
if (names.has("task") || names.has("actor")) {
|
|
1641
|
+
const target = names.has("actor") ? "`actor`" : "`task`";
|
|
1642
|
+
const available = subagents.agents.map((agent) => `\`${agent.name}\``).join(", ");
|
|
1643
|
+
instructions.push(`- Native Cursor Task/subagent requests are executed through OpenCode ${target}. ` +
|
|
1644
|
+
"Advertised custom subagent names are used exactly; otherwise `unspecified` and `generalPurpose` select host `general`, " +
|
|
1645
|
+
"`bugbot`, `security-review`, and `explore` select host `explore` (then `general`), and other specialized Cursor types fall back to `general`." +
|
|
1646
|
+
(available ? ` Spawnable host agents this turn: ${available}.` : ""));
|
|
1647
|
+
if (subagents.agents.some((agent) => agent.name === "scout")) {
|
|
1648
|
+
instructions.push("- Host `scout` is available for external documentation and dependency-source research. Use Cursor `cursor-guide` for that use case; local repository discovery still uses `bugbot`/`explore`.");
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1571
1651
|
if (names.has("write")) {
|
|
1572
1652
|
instructions.push(names.has("edit")
|
|
1573
1653
|
? "- 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."
|
|
@@ -1576,7 +1656,9 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceR
|
|
|
1576
1656
|
return [
|
|
1577
1657
|
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1578
1658
|
`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.`,
|
|
1579
|
-
|
|
1659
|
+
subagents.executor
|
|
1660
|
+
? "Call only tools in that exact list. Cursor-native Task/subagent requests are permitted because a compatible host executor is listed; other unlisted Cursor-native tools remain unavailable."
|
|
1661
|
+
: "Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
|
|
1580
1662
|
...(instructions.length > 0
|
|
1581
1663
|
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
1582
1664
|
: []),
|
|
@@ -366,10 +366,29 @@ export function createMessageTypes() {
|
|
|
366
366
|
{ id: 1, name: "path", type: "string" },
|
|
367
367
|
{ id: 2, name: "error", type: "string" },
|
|
368
368
|
]);
|
|
369
|
+
// Cursor's LocalReadExecutor validates the target before execution and, for a
|
|
370
|
+
// missing / non-regular path, returns one of these typed ReadResult cases
|
|
371
|
+
// instead of running the tool. Mirroring the exact field ids/shapes lets this
|
|
372
|
+
// provider reply the same way so the model sees a structured file-not-found
|
|
373
|
+
// observation and the host never surfaces a permission prompt.
|
|
374
|
+
addType(root, "ReadFileNotFound", [{ id: 1, name: "path", type: "string" }]);
|
|
375
|
+
addType(root, "ReadPermissionDenied", [{ id: 1, name: "path", type: "string" }]);
|
|
376
|
+
addType(root, "ReadInvalidFile", [
|
|
377
|
+
{ id: 1, name: "path", type: "string" },
|
|
378
|
+
{ id: 2, name: "reason", type: "string" },
|
|
379
|
+
]);
|
|
369
380
|
addType(root, "ReadResult", [
|
|
370
381
|
{ id: 1, name: "success", type: "ReadSuccess" },
|
|
371
382
|
{ id: 2, name: "error", type: "ReadError" },
|
|
372
|
-
|
|
383
|
+
{ id: 4, name: "file_not_found", type: "ReadFileNotFound" },
|
|
384
|
+
{ id: 5, name: "permission_denied", type: "ReadPermissionDenied" },
|
|
385
|
+
{ id: 6, name: "invalid_file", type: "ReadInvalidFile" },
|
|
386
|
+
], [
|
|
387
|
+
{
|
|
388
|
+
name: "result",
|
|
389
|
+
fields: ["success", "error", "file_not_found", "permission_denied", "invalid_file"],
|
|
390
|
+
},
|
|
391
|
+
]);
|
|
373
392
|
addType(root, "GrepArgs", [
|
|
374
393
|
{ id: 1, name: "pattern", type: "string" },
|
|
375
394
|
{ id: 2, name: "path", type: "string" },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mapCursorArgsToOpencode, mcpRealToolName } from "./tools.js";
|
|
1
|
+
import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName } from "./tools.js";
|
|
2
2
|
import { decodeStructEntriesToJson } from "./struct.js";
|
|
3
3
|
const TODO_STATUS = {
|
|
4
4
|
0: "pending",
|
|
@@ -334,7 +334,27 @@ function openCodeSubagentType(raw) {
|
|
|
334
334
|
if (typeof custom?.name === "string" && custom.name.length > 0)
|
|
335
335
|
return custom.name;
|
|
336
336
|
if (subtype.explore != null)
|
|
337
|
-
return "explore";
|
|
337
|
+
return mapCursorSubagentTypeToOpenCode("explore");
|
|
338
|
+
if (subtype.cursor_guide != null)
|
|
339
|
+
return mapCursorSubagentTypeToOpenCode("cursor_guide");
|
|
340
|
+
if (subtype.bash != null)
|
|
341
|
+
return mapCursorSubagentTypeToOpenCode("bash");
|
|
342
|
+
if (subtype.shell != null)
|
|
343
|
+
return mapCursorSubagentTypeToOpenCode("shell");
|
|
344
|
+
if (subtype.debug != null)
|
|
345
|
+
return mapCursorSubagentTypeToOpenCode("debug");
|
|
346
|
+
if (subtype.computer_use != null)
|
|
347
|
+
return mapCursorSubagentTypeToOpenCode("computer_use");
|
|
348
|
+
if (subtype.browser_use != null)
|
|
349
|
+
return mapCursorSubagentTypeToOpenCode("browser_use");
|
|
350
|
+
if (subtype.media_review != null)
|
|
351
|
+
return mapCursorSubagentTypeToOpenCode("media_review");
|
|
352
|
+
if (subtype.watch_video != null)
|
|
353
|
+
return mapCursorSubagentTypeToOpenCode("watch_video");
|
|
354
|
+
if (subtype.vm_setup_helper != null)
|
|
355
|
+
return mapCursorSubagentTypeToOpenCode("vm_setup_helper");
|
|
356
|
+
if (subtype.unspecified != null)
|
|
357
|
+
return mapCursorSubagentTypeToOpenCode("unspecified");
|
|
338
358
|
return undefined;
|
|
339
359
|
}
|
|
340
360
|
/** Advertised OpenCode tool ids from Cursor McpToolDefinition descriptors. */
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -64,6 +64,29 @@ export declare function toolsToMcpDescriptors(tools: OpencodeToolDef[], provider
|
|
|
64
64
|
export declare function buildLiveRequestContext(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Record<string, unknown>;
|
|
65
65
|
export declare function mapExecServerToToolName(execField: string): string | undefined;
|
|
66
66
|
export declare function mapToolNameToExecField(toolName: string): string | undefined;
|
|
67
|
+
export type HostSubagentDefinition = {
|
|
68
|
+
name: string;
|
|
69
|
+
description?: string;
|
|
70
|
+
};
|
|
71
|
+
export type HostSubagentCatalog = {
|
|
72
|
+
executor?: "task" | "actor";
|
|
73
|
+
agents: HostSubagentDefinition[];
|
|
74
|
+
/** True when the host tool supplied its complete, permission-filtered catalog. */
|
|
75
|
+
complete: boolean;
|
|
76
|
+
};
|
|
77
|
+
/** Extract the current host's permission-filtered Task/Actor recipient catalog. */
|
|
78
|
+
export declare function extractHostSubagentCatalog(tools: OpencodeToolDef[]): HostSubagentCatalog;
|
|
79
|
+
/** Resolve a Cursor subtype against the agents the host can spawn this turn. */
|
|
80
|
+
export declare function resolveCursorSubagentType(subagentType: string, catalog?: HostSubagentCatalog): string | undefined;
|
|
81
|
+
/**
|
|
82
|
+
* Cursor's native Task/subagent protocol uses Cursor-owned subtype names while
|
|
83
|
+
* OpenCode-family hosts execute named agents. Map known Cursor built-ins to
|
|
84
|
+
* the closest host agent and preserve unknown values so user-defined OpenCode
|
|
85
|
+
* agents can still be called by exact name.
|
|
86
|
+
*/
|
|
87
|
+
export declare function mapCursorSubagentTypeToOpenCode(subagentType: string): string;
|
|
88
|
+
/** Resolve a native Cursor subagent request to this turn's executor and catalog. */
|
|
89
|
+
export declare function remapNativeSubagentForCatalog(parsed: ParsedExecRequest, advertisedToolNames: Iterable<string>, catalog?: HostSubagentCatalog): void;
|
|
67
90
|
export type ParsedExecRequest = {
|
|
68
91
|
id: number;
|
|
69
92
|
execId: string;
|
|
@@ -92,6 +115,32 @@ export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<st
|
|
|
92
115
|
* → github_create_pull_request). Builtins under the default server stay bare.
|
|
93
116
|
*/
|
|
94
117
|
export declare function mcpRealToolName(mcpArgs: Record<string, unknown>, defaultServer?: string): string;
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a read target the way Cursor's LocalReadExecutor does before it
|
|
120
|
+
* stats the file: untildify, then resolve a relative path against the workspace
|
|
121
|
+
* root (`env.workspace_paths[0]`), else resolve as-is. Kept in lockstep with
|
|
122
|
+
* agent utils `resolvePath(path, workspaceRoot)`.
|
|
123
|
+
*/
|
|
124
|
+
export declare function resolveReadTargetPath(requested: string, workspaceRoot: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* A typed ReadResult oneof case for a read target that fails Cursor's
|
|
127
|
+
* pre-execution validation, or undefined when the path is a readable file the
|
|
128
|
+
* tool should actually read. Mirrors LocalReadExecutor exactly:
|
|
129
|
+
* - missing path (ENOENT/ENOTDIR) → file_not_found
|
|
130
|
+
* - directory → invalid_file "Path is a directory, not a file"
|
|
131
|
+
* - socket/fifo/etc (not a file) → invalid_file "Path is neither a file nor a directory"
|
|
132
|
+
* EACCES/EPERM (exists but unreadable) and any other stat error return undefined
|
|
133
|
+
* so the call proceeds to OpenCode and a genuine permission decision is never
|
|
134
|
+
* masked. Never rejects a non-existent *write/edit* target — this is read-only.
|
|
135
|
+
*/
|
|
136
|
+
export declare function classifyMissingReadTarget(absolutePath: string): Record<string, unknown> | undefined;
|
|
137
|
+
/**
|
|
138
|
+
* Encode a pre-execution read rejection as the exact frames Cursor's client
|
|
139
|
+
* emits: the ExecClientMessage carrying the typed ReadResult oneof case, then
|
|
140
|
+
* the ACM #5 stream_close for that id. `readResult` is the case object from
|
|
141
|
+
* classifyMissingReadTarget (e.g. `{ file_not_found: { path } }`).
|
|
142
|
+
*/
|
|
143
|
+
export declare function buildReadRejectionMessages(execId: number, readResult: Record<string, unknown>): Uint8Array[];
|
|
95
144
|
export type ToolResultInput = {
|
|
96
145
|
execId: number;
|
|
97
146
|
/** ExecClientMessage result field (from ParsedExecRequest.resultField). */
|
package/dist/protocol/tools.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
import { encodeMessage } from "./messages.js";
|
|
3
5
|
import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
|
|
4
6
|
import { buildEnv } from "../context/env.js";
|
|
@@ -280,6 +282,200 @@ export function mapExecServerToToolName(execField) {
|
|
|
280
282
|
export function mapToolNameToExecField(toolName) {
|
|
281
283
|
return opencodeToolToCursor[toolName];
|
|
282
284
|
}
|
|
285
|
+
const CURSOR_SUBAGENT_TYPE_TO_OPENCODE = {
|
|
286
|
+
generalPurpose: "general",
|
|
287
|
+
"general-purpose": "general",
|
|
288
|
+
general_purpose: "general",
|
|
289
|
+
general: "general",
|
|
290
|
+
unspecified: "general",
|
|
291
|
+
"cursor-guide": "explore",
|
|
292
|
+
cursor_guide: "explore",
|
|
293
|
+
"best-of-n-runner": "general",
|
|
294
|
+
best_of_n_runner: "general",
|
|
295
|
+
bash: "general",
|
|
296
|
+
shell: "general",
|
|
297
|
+
debug: "general",
|
|
298
|
+
computer_use: "general",
|
|
299
|
+
computerUse: "general",
|
|
300
|
+
browser_use: "general",
|
|
301
|
+
browserUse: "general",
|
|
302
|
+
media_review: "general",
|
|
303
|
+
mediaReview: "general",
|
|
304
|
+
watch_video: "general",
|
|
305
|
+
watchVideo: "general",
|
|
306
|
+
vm_setup_helper: "general",
|
|
307
|
+
vmSetupHelper: "general",
|
|
308
|
+
explore: "explore",
|
|
309
|
+
bugbot: "explore",
|
|
310
|
+
"security-review": "explore",
|
|
311
|
+
security_review: "explore",
|
|
312
|
+
};
|
|
313
|
+
const SUBAGENT_CATALOG_MARKER = "Available agent types and the tools they have access to:";
|
|
314
|
+
function parseSubagentDescriptionCatalog(description) {
|
|
315
|
+
if (!description)
|
|
316
|
+
return { found: false, agents: [] };
|
|
317
|
+
const marker = description.indexOf(SUBAGENT_CATALOG_MARKER);
|
|
318
|
+
if (marker < 0)
|
|
319
|
+
return { found: false, agents: [] };
|
|
320
|
+
const agents = [];
|
|
321
|
+
const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
|
|
322
|
+
let started = false;
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
|
|
325
|
+
if (!match) {
|
|
326
|
+
if (started && line.trim())
|
|
327
|
+
break;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
started = true;
|
|
331
|
+
const name = match[1].trim().replace(/^`|`$/g, "");
|
|
332
|
+
if (!name)
|
|
333
|
+
continue;
|
|
334
|
+
const agentDescription = match[2].trim();
|
|
335
|
+
agents.push({
|
|
336
|
+
name,
|
|
337
|
+
...(agentDescription ? { description: agentDescription } : {}),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { found: true, agents };
|
|
341
|
+
}
|
|
342
|
+
function subagentTypeEnumValues(schema) {
|
|
343
|
+
const out = new Set();
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
const visit = (value, propertyName) => {
|
|
346
|
+
if (!value || typeof value !== "object")
|
|
347
|
+
return;
|
|
348
|
+
if (seen.has(value))
|
|
349
|
+
return;
|
|
350
|
+
seen.add(value);
|
|
351
|
+
if (Array.isArray(value)) {
|
|
352
|
+
for (const item of value)
|
|
353
|
+
visit(item, propertyName);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const record = value;
|
|
357
|
+
if (propertyName === "subagent_type" && Array.isArray(record.enum)) {
|
|
358
|
+
for (const item of record.enum) {
|
|
359
|
+
if (typeof item === "string" && item)
|
|
360
|
+
out.add(item);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
for (const [key, child] of Object.entries(record))
|
|
364
|
+
visit(child, key);
|
|
365
|
+
};
|
|
366
|
+
visit(schema);
|
|
367
|
+
return [...out];
|
|
368
|
+
}
|
|
369
|
+
/** Extract the current host's permission-filtered Task/Actor recipient catalog. */
|
|
370
|
+
export function extractHostSubagentCatalog(tools) {
|
|
371
|
+
const executorTool = tools.find((tool) => tool.name === "actor") ??
|
|
372
|
+
tools.find((tool) => tool.name === "task");
|
|
373
|
+
if (!executorTool)
|
|
374
|
+
return { agents: [], complete: true };
|
|
375
|
+
const described = parseSubagentDescriptionCatalog(executorTool.description);
|
|
376
|
+
const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
|
|
377
|
+
const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
|
|
378
|
+
// MiMo's Actor enum is stricter than its prose catalog (`mode: subagent`
|
|
379
|
+
// rather than every non-primary agent), so structured names are authoritative.
|
|
380
|
+
const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
|
|
381
|
+
return {
|
|
382
|
+
executor: executorTool.name,
|
|
383
|
+
agents: [...names].map((name) => ({
|
|
384
|
+
name,
|
|
385
|
+
...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
|
|
386
|
+
})),
|
|
387
|
+
complete: enumNames.length > 0 || described.found,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const GENERIC_CURSOR_SUBAGENT_TYPES = new Set([
|
|
391
|
+
"generalPurpose",
|
|
392
|
+
"general-purpose",
|
|
393
|
+
"general_purpose",
|
|
394
|
+
"general",
|
|
395
|
+
"unspecified",
|
|
396
|
+
]);
|
|
397
|
+
function cursorSubagentCandidates(subagentType) {
|
|
398
|
+
if (GENERIC_CURSOR_SUBAGENT_TYPES.has(subagentType))
|
|
399
|
+
return ["general"];
|
|
400
|
+
if (subagentType === "cursor-guide" || subagentType === "cursor_guide") {
|
|
401
|
+
return ["scout", "explore", "general"];
|
|
402
|
+
}
|
|
403
|
+
if (subagentType === "explore" ||
|
|
404
|
+
subagentType === "bugbot" ||
|
|
405
|
+
subagentType === "security-review" ||
|
|
406
|
+
subagentType === "security_review") {
|
|
407
|
+
return ["explore", "general"];
|
|
408
|
+
}
|
|
409
|
+
return ["general"];
|
|
410
|
+
}
|
|
411
|
+
/** Resolve a Cursor subtype against the agents the host can spawn this turn. */
|
|
412
|
+
export function resolveCursorSubagentType(subagentType, catalog) {
|
|
413
|
+
const available = new Set(catalog?.agents.map((agent) => agent.name) ?? []);
|
|
414
|
+
// Explicit host/custom names win. `unspecified` and general aliases are
|
|
415
|
+
// intentionally excluded so they always select the host's generic agent.
|
|
416
|
+
if (!GENERIC_CURSOR_SUBAGENT_TYPES.has(subagentType) && available.has(subagentType)) {
|
|
417
|
+
return subagentType;
|
|
418
|
+
}
|
|
419
|
+
const candidates = cursorSubagentCandidates(subagentType);
|
|
420
|
+
for (const candidate of candidates) {
|
|
421
|
+
if (available.has(candidate))
|
|
422
|
+
return candidate;
|
|
423
|
+
}
|
|
424
|
+
if (catalog?.complete)
|
|
425
|
+
return undefined;
|
|
426
|
+
return candidates[0];
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Cursor's native Task/subagent protocol uses Cursor-owned subtype names while
|
|
430
|
+
* OpenCode-family hosts execute named agents. Map known Cursor built-ins to
|
|
431
|
+
* the closest host agent and preserve unknown values so user-defined OpenCode
|
|
432
|
+
* agents can still be called by exact name.
|
|
433
|
+
*/
|
|
434
|
+
export function mapCursorSubagentTypeToOpenCode(subagentType) {
|
|
435
|
+
return CURSOR_SUBAGENT_TYPE_TO_OPENCODE[subagentType] ?? subagentType;
|
|
436
|
+
}
|
|
437
|
+
/** Resolve a native Cursor subagent request to this turn's executor and catalog. */
|
|
438
|
+
export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catalog) {
|
|
439
|
+
if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
|
|
440
|
+
return;
|
|
441
|
+
const advertised = new Set(advertisedToolNames);
|
|
442
|
+
const executor = advertised.has("actor") ? "actor" : advertised.has("task") ? "task" : undefined;
|
|
443
|
+
if (!executor)
|
|
444
|
+
return;
|
|
445
|
+
const description = str(parsed.args.description) ?? "";
|
|
446
|
+
const prompt = str(parsed.args.prompt) ?? "";
|
|
447
|
+
const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
|
|
448
|
+
str(parsed.args.subagent_type) ?? "";
|
|
449
|
+
const subagentType = resolveCursorSubagentType(cursorSubagentType, catalog);
|
|
450
|
+
if (!subagentType) {
|
|
451
|
+
const available = catalog?.agents.map((agent) => agent.name).join(", ") || "none";
|
|
452
|
+
parsed.localError =
|
|
453
|
+
`Cursor subagent '${cursorSubagentType}' has no compatible host agent. ` +
|
|
454
|
+
`Available subagents: ${available}.`;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const resumeAgentId = str(parsed.args.task_id);
|
|
458
|
+
parsed.toolName = executor;
|
|
459
|
+
if (executor === "task") {
|
|
460
|
+
parsed.args = {
|
|
461
|
+
description,
|
|
462
|
+
prompt,
|
|
463
|
+
subagent_type: subagentType,
|
|
464
|
+
...(resumeAgentId ? { task_id: resumeAgentId } : {}),
|
|
465
|
+
...(parsed.args.background === true ? { background: true } : {}),
|
|
466
|
+
};
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
parsed.args = {
|
|
470
|
+
operation: {
|
|
471
|
+
action: parsed.args.background === true ? "spawn" : "run",
|
|
472
|
+
description,
|
|
473
|
+
prompt,
|
|
474
|
+
subagent_type: subagentType,
|
|
475
|
+
...(resumeAgentId ? { actor_id: resumeAgentId } : {}),
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
283
479
|
export function parseExecServerMessage(msg) {
|
|
284
480
|
const id = msg.id;
|
|
285
481
|
if (id === undefined)
|
|
@@ -358,6 +554,9 @@ export function parseExecServerMessage(msg) {
|
|
|
358
554
|
toolName: "task",
|
|
359
555
|
args,
|
|
360
556
|
resultField,
|
|
557
|
+
resultMetadata: cursorSubagentType
|
|
558
|
+
? { cursor_subagent_type: cursorSubagentType }
|
|
559
|
+
: undefined,
|
|
361
560
|
localError: prompt && cursorSubagentType
|
|
362
561
|
? undefined
|
|
363
562
|
: "Cursor subagent request is missing a required prompt or subagent type.",
|
|
@@ -595,19 +794,6 @@ function describeSubagentTask(prompt, subagentType) {
|
|
|
595
794
|
return words.join(" ");
|
|
596
795
|
return `${subagentType || "Delegated"} task`;
|
|
597
796
|
}
|
|
598
|
-
function mapCursorSubagentTypeToOpenCode(subagentType) {
|
|
599
|
-
// Cursor's built-in general agent uses a camelCase protocol identifier, and
|
|
600
|
-
// its native Bugbot reviewer has no OpenCode-specific agent definition.
|
|
601
|
-
// OpenCode `general` is the general-purpose equivalent; `explore` preserves
|
|
602
|
-
// Bugbot's review-only/read-oriented semantics while retaining grep/read/bash.
|
|
603
|
-
// Preserve every other value so `explore` and configured custom agents keep
|
|
604
|
-
// their exact advertised names.
|
|
605
|
-
if (subagentType === "generalPurpose")
|
|
606
|
-
return "general";
|
|
607
|
-
if (subagentType === "bugbot")
|
|
608
|
-
return "explore";
|
|
609
|
-
return subagentType;
|
|
610
|
-
}
|
|
611
797
|
function shellQuote(s) {
|
|
612
798
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
613
799
|
}
|
|
@@ -667,6 +853,75 @@ function findOneOfVariant(msg, candidates) {
|
|
|
667
853
|
}
|
|
668
854
|
return undefined;
|
|
669
855
|
}
|
|
856
|
+
// ── Pre-execution read validation (Cursor LocalReadExecutor parity) ──
|
|
857
|
+
/** Expand a leading `~` to the home directory, matching Cursor's untildify. */
|
|
858
|
+
function untildify(input) {
|
|
859
|
+
return input.replace(/^~(?=$|\/|\\)/, os.homedir());
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Resolve a read target the way Cursor's LocalReadExecutor does before it
|
|
863
|
+
* stats the file: untildify, then resolve a relative path against the workspace
|
|
864
|
+
* root (`env.workspace_paths[0]`), else resolve as-is. Kept in lockstep with
|
|
865
|
+
* agent utils `resolvePath(path, workspaceRoot)`.
|
|
866
|
+
*/
|
|
867
|
+
export function resolveReadTargetPath(requested, workspaceRoot) {
|
|
868
|
+
const expanded = untildify(requested);
|
|
869
|
+
if (workspaceRoot && !path.isAbsolute(expanded)) {
|
|
870
|
+
return path.resolve(workspaceRoot, expanded);
|
|
871
|
+
}
|
|
872
|
+
return path.resolve(expanded);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* A typed ReadResult oneof case for a read target that fails Cursor's
|
|
876
|
+
* pre-execution validation, or undefined when the path is a readable file the
|
|
877
|
+
* tool should actually read. Mirrors LocalReadExecutor exactly:
|
|
878
|
+
* - missing path (ENOENT/ENOTDIR) → file_not_found
|
|
879
|
+
* - directory → invalid_file "Path is a directory, not a file"
|
|
880
|
+
* - socket/fifo/etc (not a file) → invalid_file "Path is neither a file nor a directory"
|
|
881
|
+
* EACCES/EPERM (exists but unreadable) and any other stat error return undefined
|
|
882
|
+
* so the call proceeds to OpenCode and a genuine permission decision is never
|
|
883
|
+
* masked. Never rejects a non-existent *write/edit* target — this is read-only.
|
|
884
|
+
*/
|
|
885
|
+
export function classifyMissingReadTarget(absolutePath) {
|
|
886
|
+
let stat;
|
|
887
|
+
try {
|
|
888
|
+
stat = fs.statSync(absolutePath);
|
|
889
|
+
}
|
|
890
|
+
catch (e) {
|
|
891
|
+
const code = e.code;
|
|
892
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
893
|
+
return { file_not_found: { path: absolutePath } };
|
|
894
|
+
}
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
if (stat.isDirectory()) {
|
|
898
|
+
return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
|
|
899
|
+
}
|
|
900
|
+
if (!stat.isFile()) {
|
|
901
|
+
return {
|
|
902
|
+
invalid_file: { path: absolutePath, reason: "Path is neither a file nor a directory" },
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
return undefined;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Encode a pre-execution read rejection as the exact frames Cursor's client
|
|
909
|
+
* emits: the ExecClientMessage carrying the typed ReadResult oneof case, then
|
|
910
|
+
* the ACM #5 stream_close for that id. `readResult` is the case object from
|
|
911
|
+
* classifyMissingReadTarget (e.g. `{ file_not_found: { path } }`).
|
|
912
|
+
*/
|
|
913
|
+
export function buildReadRejectionMessages(execId, readResult) {
|
|
914
|
+
return [
|
|
915
|
+
encodeMessage("AgentClientMessage", {
|
|
916
|
+
exec_client_message: {
|
|
917
|
+
id: execId,
|
|
918
|
+
local_execution_time_ms: 0,
|
|
919
|
+
read_result: readResult,
|
|
920
|
+
},
|
|
921
|
+
}),
|
|
922
|
+
buildExecStreamClose(execId),
|
|
923
|
+
];
|
|
924
|
+
}
|
|
670
925
|
/**
|
|
671
926
|
* Build one or more ExecClientMessage frames for a tool result.
|
|
672
927
|
* Shell replies are a sequence of ShellStream oneofs under the same id —
|
package/dist/session.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { BidiStream } from "./transport/connect.js";
|
|
2
2
|
import { type CursorProviderError } from "./errors.js";
|
|
3
3
|
import { type SessionActivitySource } from "./activity.js";
|
|
4
|
-
import type { ToolAliasRegistry } from "./protocol/tools.js";
|
|
4
|
+
import type { HostSubagentCatalog, ToolAliasRegistry } from "./protocol/tools.js";
|
|
5
5
|
export type Frame = {
|
|
6
6
|
flags: number;
|
|
7
7
|
payload: Uint8Array;
|
|
@@ -120,6 +120,8 @@ export type CursorSession = {
|
|
|
120
120
|
toolDescriptors: Array<Record<string, unknown>>;
|
|
121
121
|
/** Cursor-facing web alias → exact executable host tool for this Run. */
|
|
122
122
|
toolAliases?: ToolAliasRegistry;
|
|
123
|
+
/** Spawnable host agents extracted from this turn's Task/Actor definition. */
|
|
124
|
+
subagentCatalog?: HostSubagentCatalog;
|
|
123
125
|
/** Full RequestContext for exec #10 replies. */
|
|
124
126
|
requestContext: Record<string, unknown>;
|
|
125
127
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursor-opencode-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Use Cursor subscription models from OpenCode via Cursor's Connect-RPC agent protocol",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -75,4 +75,4 @@
|
|
|
75
75
|
"@types/node": "^22.15.3",
|
|
76
76
|
"typescript": "^5.8.3"
|
|
77
77
|
}
|
|
78
|
-
}
|
|
78
|
+
}
|