cursor-opencode-provider 0.2.7 → 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.
@@ -82,6 +82,88 @@ export function readAllFields(b) {
82
82
  }
83
83
  return out;
84
84
  }
85
+ const MAX_UINT32 = 0xffffffffn;
86
+ const MAX_UINT64 = 0xffffffffffffffffn;
87
+ const MAX_PROTOBUF_FIELD_NUMBER = 0x1fff_ffff;
88
+ /**
89
+ * Decode one unsigned protobuf varint without JavaScript's 32-bit bitwise
90
+ * truncation. `maximum` also enforces the protocol width: tags and lengths are
91
+ * uint32, while wire-type 0 scalar values may use the full uint64 range.
92
+ */
93
+ function readBoundedVarint(bytes, offset, maximum) {
94
+ let value = 0n;
95
+ let shift = 0n;
96
+ for (let index = offset; index < bytes.length && index < offset + 10; index++) {
97
+ const byte = bytes[index];
98
+ value |= BigInt(byte & 0x7f) << shift;
99
+ if (value > maximum)
100
+ return undefined;
101
+ if ((byte & 0x80) === 0)
102
+ return { value, nextOffset: index + 1 };
103
+ shift += 7n;
104
+ }
105
+ return undefined;
106
+ }
107
+ /**
108
+ * Strictly walk a protobuf message for security/replay decisions.
109
+ *
110
+ * Unlike `readAllFields`, this parser consumes the complete input, preserves
111
+ * uint64 varints as bigint, represents fixed-width fields, and rejects invalid
112
+ * tags, truncation, overflow, groups, and unsupported wire types. It deliberately
113
+ * returns `undefined` instead of a partial result: callers must fail closed when
114
+ * deciding whether replay is safe.
115
+ */
116
+ export function readAllFieldsStrict(bytes) {
117
+ let offset = 0;
118
+ const fields = [];
119
+ while (offset < bytes.length) {
120
+ const tag = readBoundedVarint(bytes, offset, MAX_UINT32);
121
+ if (!tag)
122
+ return undefined;
123
+ offset = tag.nextOffset;
124
+ const fieldNumber = Number(tag.value >> 3n);
125
+ const wireType = Number(tag.value & 7n);
126
+ if (fieldNumber === 0 || fieldNumber > MAX_PROTOBUF_FIELD_NUMBER)
127
+ return undefined;
128
+ if (wireType === 0) {
129
+ const scalar = readBoundedVarint(bytes, offset, MAX_UINT64);
130
+ if (!scalar)
131
+ return undefined;
132
+ fields.push({ fn: fieldNumber, wt: wireType, varint: scalar.value });
133
+ offset = scalar.nextOffset;
134
+ continue;
135
+ }
136
+ if (wireType === 1) {
137
+ if (offset + 8 > bytes.length)
138
+ return undefined;
139
+ fields.push({ fn: fieldNumber, wt: wireType, fixed64: bytes.subarray(offset, offset + 8) });
140
+ offset += 8;
141
+ continue;
142
+ }
143
+ if (wireType === 2) {
144
+ const encodedLength = readBoundedVarint(bytes, offset, MAX_UINT32);
145
+ if (!encodedLength)
146
+ return undefined;
147
+ offset = encodedLength.nextOffset;
148
+ const length = Number(encodedLength.value);
149
+ if (offset + length > bytes.length)
150
+ return undefined;
151
+ fields.push({ fn: fieldNumber, wt: wireType, bytes: bytes.subarray(offset, offset + length) });
152
+ offset += length;
153
+ continue;
154
+ }
155
+ if (wireType === 5) {
156
+ if (offset + 4 > bytes.length)
157
+ return undefined;
158
+ fields.push({ fn: fieldNumber, wt: wireType, fixed32: bytes.subarray(offset, offset + 4) });
159
+ offset += 4;
160
+ continue;
161
+ }
162
+ // Groups (3/4) are deprecated and recursive; no replay-safe frame uses them.
163
+ return undefined;
164
+ }
165
+ return fields;
166
+ }
85
167
  function decodeStructBytes(b) {
86
168
  const obj = {};
87
169
  for (const f of readAllFields(b)) {
@@ -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. */
@@ -4,6 +4,17 @@ export type OpencodeToolDef = {
4
4
  name: string;
5
5
  description?: string;
6
6
  inputSchema?: unknown;
7
+ /** Original flattened identity when `name` is a Cursor-facing alias. */
8
+ sourceName?: string;
9
+ };
10
+ export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
11
+ export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
12
+ /** Cursor-facing alias → exact host tool name accepted by the AI SDK call. */
13
+ export type ToolAliasRegistry = ReadonlyMap<string, string>;
14
+ export type AliasedToolCatalog = {
15
+ advertisedTools: OpencodeToolDef[];
16
+ aliases: ToolAliasRegistry;
17
+ ambiguous: ReadonlyMap<string, string[]>;
7
18
  };
8
19
  export type ToolServerIdentity = {
9
20
  /** Cursor MCP server id / provider_identifier (e.g. opencode, github). */
@@ -32,6 +43,13 @@ export declare function resolveToolServerIdentity(opencodeName: string, defaultS
32
43
  * reconstructed in `mcpRealToolName`.
33
44
  */
34
45
  export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
46
+ /**
47
+ * Give collision-prone web capabilities names Cursor will not confuse with its
48
+ * native UI-bound WebSearch/WebFetch interactions. Exact host tools win; a
49
+ * flattened MCP suffix is accepted only when it identifies one unique tool.
50
+ */
51
+ export declare function buildCustomWebToolAliases(tools: OpencodeToolDef[]): AliasedToolCatalog;
52
+ export declare function resolveCustomWebToolAlias(toolName: string, aliases: ToolAliasRegistry | undefined): string;
35
53
  /**
36
54
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
37
55
  * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins
@@ -46,6 +64,29 @@ export declare function toolsToMcpDescriptors(tools: OpencodeToolDef[], provider
46
64
  export declare function buildLiveRequestContext(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Record<string, unknown>;
47
65
  export declare function mapExecServerToToolName(execField: string): string | undefined;
48
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;
49
90
  export type ParsedExecRequest = {
50
91
  id: number;
51
92
  execId: string;
@@ -74,6 +115,32 @@ export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<st
74
115
  * → github_create_pull_request). Builtins under the default server stay bare.
75
116
  */
76
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[];
77
144
  export type ToolResultInput = {
78
145
  execId: number;
79
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";
@@ -10,6 +12,8 @@ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-
10
12
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
11
13
  // field number for every exec variant, so this is also the result field.
12
14
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
15
+ export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
16
+ export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
13
17
  /** Match OpenCode's McpCatalog.sanitize for config server ids. */
14
18
  export function sanitizeMcpServerId(value) {
15
19
  return value.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -51,13 +55,17 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
51
55
  */
52
56
  export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
53
57
  return tools.map((t) => {
54
- const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
58
+ const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
59
+ const collisionSafeWebAlias = t.name === CUSTOM_WEBSEARCH_TOOL || t.name === CUSTOM_WEBFETCH_TOOL;
55
60
  return {
56
- name: `${id.server}-${id.toolName}`,
61
+ // Keep the collision-safe public name exact. Prefixing it with the
62
+ // synthetic default server weakens the distinction from Cursor-native
63
+ // web tools in the model-visible catalog.
64
+ name: collisionSafeWebAlias ? t.name : `${id.server}-${id.toolName}`,
57
65
  description: t.description ?? "",
58
66
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
59
67
  provider_identifier: id.server,
60
- tool_name: id.toolName,
68
+ tool_name: t.sourceName ? t.name : id.toolName,
61
69
  };
62
70
  });
63
71
  }
@@ -67,6 +75,67 @@ function normalizeInputSchema(schema) {
67
75
  }
68
76
  return { type: "object", properties: {} };
69
77
  }
78
+ const WEB_ALIAS_RULES = [
79
+ {
80
+ alias: CUSTOM_WEBSEARCH_TOOL,
81
+ exact: ["websearch", "web_search"],
82
+ suffixes: ["_web_search", "-web_search", "_websearch", "-websearch"],
83
+ },
84
+ {
85
+ alias: CUSTOM_WEBFETCH_TOOL,
86
+ exact: ["webfetch", "web_fetch"],
87
+ suffixes: ["_web_fetch", "-web_fetch", "_webfetch", "-webfetch"],
88
+ },
89
+ ];
90
+ /**
91
+ * Give collision-prone web capabilities names Cursor will not confuse with its
92
+ * native UI-bound WebSearch/WebFetch interactions. Exact host tools win; a
93
+ * flattened MCP suffix is accepted only when it identifies one unique tool.
94
+ */
95
+ export function buildCustomWebToolAliases(tools) {
96
+ const aliases = new Map();
97
+ const ambiguous = new Map();
98
+ const replacements = new Map();
99
+ for (const rule of WEB_ALIAS_RULES) {
100
+ // A host/plugin may already expose the collision-safe public name. Keep it
101
+ // authoritative instead of hiding it behind a second mapping.
102
+ if (tools.some((tool) => tool.name === rule.alias))
103
+ continue;
104
+ const exact = tools.filter((tool) => rule.exact.includes(tool.name.toLowerCase()));
105
+ const candidates = exact.length > 0
106
+ ? exact
107
+ : tools.filter((tool) => {
108
+ const name = tool.name.toLowerCase();
109
+ return rule.suffixes.some((suffix) => name.endsWith(suffix));
110
+ });
111
+ if (candidates.length !== 1) {
112
+ if (candidates.length > 1)
113
+ ambiguous.set(rule.alias, candidates.map((tool) => tool.name));
114
+ continue;
115
+ }
116
+ const original = candidates[0].name;
117
+ aliases.set(rule.alias, original);
118
+ replacements.set(original, rule.alias);
119
+ }
120
+ return {
121
+ advertisedTools: tools.map((tool) => {
122
+ const alias = replacements.get(tool.name);
123
+ return alias ? { ...tool, name: alias, sourceName: tool.name } : { ...tool };
124
+ }),
125
+ aliases,
126
+ ambiguous,
127
+ };
128
+ }
129
+ export function resolveCustomWebToolAlias(toolName, aliases) {
130
+ const direct = aliases?.get(toolName);
131
+ if (direct)
132
+ return direct;
133
+ for (const [alias, original] of aliases ?? []) {
134
+ if (toolName.endsWith(`_${alias}`))
135
+ return original;
136
+ }
137
+ return toolName;
138
+ }
70
139
  /**
71
140
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
72
141
  * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins
@@ -79,7 +148,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
79
148
  const order = [];
80
149
  const byServer = new Map();
81
150
  for (const t of tools) {
82
- const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
151
+ const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
83
152
  let list = byServer.get(id.server);
84
153
  if (!list) {
85
154
  list = [];
@@ -87,7 +156,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
87
156
  order.push(id.server);
88
157
  }
89
158
  list.push({
90
- tool_name: id.toolName,
159
+ tool_name: t.sourceName ? t.name : id.toolName,
91
160
  description: t.description ?? "",
92
161
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
93
162
  });
@@ -118,6 +187,8 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
118
187
  enabled: true,
119
188
  mcp_descriptors: nested,
120
189
  },
190
+ web_search_enabled: false,
191
+ web_fetch_enabled: false,
121
192
  rules_info_complete: true,
122
193
  env_info_complete: true,
123
194
  repository_info_complete: true,
@@ -211,6 +282,200 @@ export function mapExecServerToToolName(execField) {
211
282
  export function mapToolNameToExecField(toolName) {
212
283
  return opencodeToolToCursor[toolName];
213
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
+ }
214
479
  export function parseExecServerMessage(msg) {
215
480
  const id = msg.id;
216
481
  if (id === undefined)
@@ -289,6 +554,9 @@ export function parseExecServerMessage(msg) {
289
554
  toolName: "task",
290
555
  args,
291
556
  resultField,
557
+ resultMetadata: cursorSubagentType
558
+ ? { cursor_subagent_type: cursorSubagentType }
559
+ : undefined,
292
560
  localError: prompt && cursorSubagentType
293
561
  ? undefined
294
562
  : "Cursor subagent request is missing a required prompt or subagent type.",
@@ -526,19 +794,6 @@ function describeSubagentTask(prompt, subagentType) {
526
794
  return words.join(" ");
527
795
  return `${subagentType || "Delegated"} task`;
528
796
  }
529
- function mapCursorSubagentTypeToOpenCode(subagentType) {
530
- // Cursor's built-in general agent uses a camelCase protocol identifier, and
531
- // its native Bugbot reviewer has no OpenCode-specific agent definition.
532
- // OpenCode `general` is the general-purpose equivalent; `explore` preserves
533
- // Bugbot's review-only/read-oriented semantics while retaining grep/read/bash.
534
- // Preserve every other value so `explore` and configured custom agents keep
535
- // their exact advertised names.
536
- if (subagentType === "generalPurpose")
537
- return "general";
538
- if (subagentType === "bugbot")
539
- return "explore";
540
- return subagentType;
541
- }
542
797
  function shellQuote(s) {
543
798
  return `'${s.replace(/'/g, `'\\''`)}'`;
544
799
  }
@@ -598,6 +853,75 @@ function findOneOfVariant(msg, candidates) {
598
853
  }
599
854
  return undefined;
600
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
+ }
601
925
  /**
602
926
  * Build one or more ExecClientMessage frames for a tool result.
603
927
  * Shell replies are a sequence of ShellStream oneofs under the same id —
@@ -0,0 +1,23 @@
1
+ import type { CursorProviderError } from "./errors.js";
2
+ export type ReplayBarrierReason = "visible-text" | "visible-reasoning" | "display-tool-lifecycle" | "non-control-exec" | "stateful-interaction" | "unknown-or-malformed-frame";
3
+ export declare class AttemptReplaySafety {
4
+ private readonly sessionId;
5
+ private barrierReason;
6
+ constructor(sessionId: string);
7
+ markBarrier(reason: ReplayBarrierReason): void;
8
+ applyTo(failure: CursorProviderError): CursorProviderError;
9
+ }
10
+ export type DecodedReplayFrame = {
11
+ interactionUpdate?: Record<string, unknown>;
12
+ exec?: Record<string, unknown>;
13
+ kv?: Record<string, unknown>;
14
+ execControl?: Record<string, unknown>;
15
+ interactionQuery?: Record<string, unknown>;
16
+ checkpointBytes?: Uint8Array;
17
+ };
18
+ export type ReplayFrameAnalysis = {
19
+ semanticProgress: boolean;
20
+ barrier?: ReplayBarrierReason;
21
+ };
22
+ /** Classify one decoded server frame without performing any protocol side effects. */
23
+ export declare function analyzeReplayFrame(payload: Uint8Array, decoded: DecodedReplayFrame): ReplayFrameAnalysis;