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 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/Task execution (Cursor's `generalPurpose` maps to OpenCode `general`; read-oriented `bugbot` reviews map to `explore`) and the Pi read/bash/edit/write/grep/find/ls request/result field range; enforces the exact current OpenCode agent catalog before emitting any tool call; mirrors finalized display-only todo/plan state into OpenCode; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
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.8"`.
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
@@ -4,5 +4,9 @@ export type CollectedAgent = {
4
4
  description: string;
5
5
  prompt: string;
6
6
  };
7
- /** Custom OpenCode agents from `.opencode/agents` and global config. */
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[]>;
@@ -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 { opencodeGlobalConfigDir } from "./paths.js";
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
- async function scanAgentDir(dir, out) {
40
- if (!(await exists(dir)))
41
- return;
42
- let entries;
43
- try {
44
- entries = await readdir(dir);
45
- }
46
- catch {
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
- for (const name of entries) {
50
- if (!name.endsWith(".md"))
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
- const raw = await readFile(full, "utf-8");
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
- /* skip */
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
- /** Custom OpenCode agents from `.opencode/agents` and global config. */
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
- await scanAgentDir(path.join(workspaceRoot, ".opencode", "agents"), out);
74
- await scanAgentDir(path.join(opencodeGlobalConfigDir(), "agents"), out);
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
  }
@@ -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: agents.map((a) => ({
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: true,
126
+ custom_subagents_info_complete: advertisedSubagents.complete,
78
127
  mcp_file_system_info_complete: true,
79
128
  mcp_info_complete: true,
80
129
  };
@@ -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;
@@ -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) {
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { opencodeGlobalConfigDir } from "./paths.js";
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 p of await listLocalPlugins(path.join(workspaceRoot, ".opencode", "plugins"))) {
38
- if (seen.has(p.id))
39
- continue;
40
- seen.add(p.id);
41
- out.push(p);
42
- }
43
- for (const p of await listLocalPlugins(path.join(opencodeGlobalConfigDir(), "plugins"))) {
44
- if (seen.has(p.id))
45
- continue;
46
- seen.add(p.id);
47
- out.push(p);
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
  }
@@ -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 { opencodeGlobalConfigDir, resolveHomeRelative } from "./paths.js";
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 ["opencode.json", "opencode.jsonc"]) {
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
- export async function loadMergedConfig(workspaceRoot) {
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
- ...globalConfig,
169
- ...projectConfig,
170
- instructions: [...(globalConfig.instructions ?? []), ...(projectConfig.instructions ?? [])],
171
- plugin: [...new Set([...(globalConfig.plugin ?? []), ...(projectConfig.plugin ?? [])])],
172
- plugins: [...new Set([...(globalConfig.plugins ?? []), ...(projectConfig.plugins ?? [])])],
173
- mcp: { ...(globalConfig.mcp ?? {}), ...(projectConfig.mcp ?? {}) },
174
- permission: projectConfig.permission ?? globalConfig.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
- await add(path.join(opencodeGlobalConfigDir(), "AGENTS.md"));
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://")) {
@@ -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 { opencodeGlobalConfigDir } from "./paths.js";
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
- await walkAncestorsFor(workspaceRoot, path.join(".opencode", "skills"), worktree, out);
98
- await scanSkillsRoot(path.join(opencodeGlobalConfigDir(), "skills"), out);
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);
@@ -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
- "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.",
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
- ], [{ name: "result", fields: ["success", "error"] }]);
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. */
@@ -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). */
@@ -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.8",
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
+ }