cursor-opencode-provider 0.2.6 → 0.2.8

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.
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { FALLBACK_CLIENT_VERSION, MODEL_CACHE_TTL_MS, VERSION_CACHE_FILE, } from "../shared.js";
4
4
  import { opencodeGlobalCacheDir } from "../context/paths.js";
5
+ import { withAbortDeadline } from "../deadline.js";
5
6
  const INSTALL_URL = "https://cursor.com/install";
6
7
  const REMOTE_TIMEOUT_MS = 5_000;
7
8
  const BUILD_RE = /^\d{4}\.\d{2}\.\d{2}-[0-9A-Za-z][0-9A-Za-z.-]*$/;
@@ -103,13 +104,13 @@ function isCacheFresh(cache, now = Date.now()) {
103
104
  return age >= 0 && age < MODEL_CACHE_TTL_MS;
104
105
  }
105
106
  async function fetchInstallerVersion() {
106
- const response = await fetch(INSTALL_URL, {
107
- signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
107
+ return withAbortDeadline(REMOTE_TIMEOUT_MS, () => new Error("Cursor installer version request timed out"), async (signal) => {
108
+ const response = await fetch(INSTALL_URL, { signal });
109
+ if (!response.ok)
110
+ return undefined;
111
+ const build = extractVersionFromInstaller(await response.text());
112
+ return build ? `cli-${build}` : undefined;
108
113
  });
109
- if (!response.ok)
110
- return undefined;
111
- const build = extractVersionFromInstaller(await response.text());
112
- return build ? `cli-${build}` : undefined;
113
114
  }
114
115
  async function refreshVersionCache() {
115
116
  const version = await fetchInstallerVersion();
@@ -653,8 +653,10 @@ export function createMessageTypes() {
653
653
  { id: 2, name: "workspace_paths", type: "string", repeated: true },
654
654
  { id: 3, name: "shell", type: "string" },
655
655
  { id: 5, name: "sandbox_enabled", type: "bool" },
656
+ { id: 7, name: "terminals_folder", type: "string" },
656
657
  { id: 10, name: "time_zone", type: "string" },
657
658
  { id: 11, name: "project_folder", type: "string" },
659
+ { id: 12, name: "agent_transcripts_folder", type: "string" },
658
660
  { id: 14, name: "sandbox_supported", type: "bool" },
659
661
  { id: 20, name: "is_working_dir_home_dir", type: "bool" },
660
662
  { id: 21, name: "process_working_directory", type: "string" },
@@ -722,8 +724,10 @@ export function createMessageTypes() {
722
724
  { id: 7, name: "tools", type: "McpToolDefinition", repeated: true },
723
725
  { id: 11, name: "git_repos", type: "GitRepoInfo", repeated: true },
724
726
  { id: 13, name: "project_layouts", type: "LsDirectoryTreeNode", repeated: true },
727
+ { id: 17, name: "web_search_enabled", type: "bool" },
725
728
  { id: 22, name: "custom_subagents", type: "CustomSubagent", repeated: true },
726
729
  { id: 23, name: "mcp_file_system_options", type: "McpFileSystemOptions" },
730
+ { id: 24, name: "web_fetch_enabled", type: "bool" },
727
731
  { id: 25, name: "hooks_additional_context", type: "string" },
728
732
  { id: 29, name: "agent_skills", type: "AgentSkill", repeated: true },
729
733
  { id: 33, name: "git_repo_info_complete", type: "bool" },
@@ -881,8 +885,10 @@ export function createMessageTypes() {
881
885
  { id: 7, name: "tools", type: "McpToolDefinition", repeated: true },
882
886
  { id: 11, name: "git_repos", type: "GitRepoInfo", repeated: true },
883
887
  { id: 13, name: "project_layouts", type: "LsDirectoryTreeNode", repeated: true },
888
+ { id: 17, name: "web_search_enabled", type: "bool" },
884
889
  { id: 22, name: "custom_subagents", type: "CustomSubagent", repeated: true },
885
890
  { id: 23, name: "mcp_file_system_options", type: "McpFileSystemOptions" },
891
+ { id: 24, name: "web_fetch_enabled", type: "bool" },
886
892
  { id: 25, name: "hooks_additional_context", type: "string" },
887
893
  { id: 29, name: "agent_skills", type: "AgentSkill", repeated: true },
888
894
  { id: 33, name: "git_repo_info_complete", type: "bool" },
@@ -7,6 +7,24 @@ export type RawField = {
7
7
  };
8
8
  /** Walk a protobuf message's top-level fields off the raw wire bytes. */
9
9
  export declare function readAllFields(b: Uint8Array): RawField[];
10
+ export type StrictRawField = {
11
+ fn: number;
12
+ wt: number;
13
+ varint?: bigint;
14
+ bytes?: Uint8Array;
15
+ fixed64?: Uint8Array;
16
+ fixed32?: Uint8Array;
17
+ };
18
+ /**
19
+ * Strictly walk a protobuf message for security/replay decisions.
20
+ *
21
+ * Unlike `readAllFields`, this parser consumes the complete input, preserves
22
+ * uint64 varints as bigint, represents fixed-width fields, and rejects invalid
23
+ * tags, truncation, overflow, groups, and unsupported wire types. It deliberately
24
+ * returns `undefined` instead of a partial result: callers must fail closed when
25
+ * deciding whether replay is safe.
26
+ */
27
+ export declare function readAllFieldsStrict(bytes: Uint8Array): StrictRawField[] | undefined;
10
28
  /** Decode a google.protobuf.Value message (bytes) back into a JSON value. */
11
29
  export declare function decodeValueToJson(bytes: Uint8Array): unknown;
12
30
  /**
@@ -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)) {
@@ -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
@@ -1,13 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import { encodeMessage } from "./messages.js";
3
3
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
4
- import { trace } from "../debug.js";
4
+ import { buildEnv } from "../context/env.js";
5
+ import { ensureOpencodeProjectDir } from "../context/paths.js";
6
+ import { trace, traceRequestContextPaths } from "../debug.js";
5
7
  import { cursorExecVariantByRequestName } from "./exec-variants.js";
6
- import { BACKGROUND_SHELL_MARKER } from "../shell-timeout.js";
8
+ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
7
9
  // Exec variant field number whose reply is the server-initiated request_context
8
10
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
9
11
  // field number for every exec variant, so this is also the result field.
10
12
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
13
+ export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
14
+ export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
11
15
  /** Match OpenCode's McpCatalog.sanitize for config server ids. */
12
16
  export function sanitizeMcpServerId(value) {
13
17
  return value.replace(/[^a-zA-Z0-9_-]/g, "_");
@@ -49,13 +53,17 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
49
53
  */
50
54
  export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
51
55
  return tools.map((t) => {
52
- const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
56
+ const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
57
+ const collisionSafeWebAlias = t.name === CUSTOM_WEBSEARCH_TOOL || t.name === CUSTOM_WEBFETCH_TOOL;
53
58
  return {
54
- name: `${id.server}-${id.toolName}`,
59
+ // Keep the collision-safe public name exact. Prefixing it with the
60
+ // synthetic default server weakens the distinction from Cursor-native
61
+ // web tools in the model-visible catalog.
62
+ name: collisionSafeWebAlias ? t.name : `${id.server}-${id.toolName}`,
55
63
  description: t.description ?? "",
56
64
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
57
65
  provider_identifier: id.server,
58
- tool_name: id.toolName,
66
+ tool_name: t.sourceName ? t.name : id.toolName,
59
67
  };
60
68
  });
61
69
  }
@@ -65,6 +73,67 @@ function normalizeInputSchema(schema) {
65
73
  }
66
74
  return { type: "object", properties: {} };
67
75
  }
76
+ const WEB_ALIAS_RULES = [
77
+ {
78
+ alias: CUSTOM_WEBSEARCH_TOOL,
79
+ exact: ["websearch", "web_search"],
80
+ suffixes: ["_web_search", "-web_search", "_websearch", "-websearch"],
81
+ },
82
+ {
83
+ alias: CUSTOM_WEBFETCH_TOOL,
84
+ exact: ["webfetch", "web_fetch"],
85
+ suffixes: ["_web_fetch", "-web_fetch", "_webfetch", "-webfetch"],
86
+ },
87
+ ];
88
+ /**
89
+ * Give collision-prone web capabilities names Cursor will not confuse with its
90
+ * native UI-bound WebSearch/WebFetch interactions. Exact host tools win; a
91
+ * flattened MCP suffix is accepted only when it identifies one unique tool.
92
+ */
93
+ export function buildCustomWebToolAliases(tools) {
94
+ const aliases = new Map();
95
+ const ambiguous = new Map();
96
+ const replacements = new Map();
97
+ for (const rule of WEB_ALIAS_RULES) {
98
+ // A host/plugin may already expose the collision-safe public name. Keep it
99
+ // authoritative instead of hiding it behind a second mapping.
100
+ if (tools.some((tool) => tool.name === rule.alias))
101
+ continue;
102
+ const exact = tools.filter((tool) => rule.exact.includes(tool.name.toLowerCase()));
103
+ const candidates = exact.length > 0
104
+ ? exact
105
+ : tools.filter((tool) => {
106
+ const name = tool.name.toLowerCase();
107
+ return rule.suffixes.some((suffix) => name.endsWith(suffix));
108
+ });
109
+ if (candidates.length !== 1) {
110
+ if (candidates.length > 1)
111
+ ambiguous.set(rule.alias, candidates.map((tool) => tool.name));
112
+ continue;
113
+ }
114
+ const original = candidates[0].name;
115
+ aliases.set(rule.alias, original);
116
+ replacements.set(original, rule.alias);
117
+ }
118
+ return {
119
+ advertisedTools: tools.map((tool) => {
120
+ const alias = replacements.get(tool.name);
121
+ return alias ? { ...tool, name: alias, sourceName: tool.name } : { ...tool };
122
+ }),
123
+ aliases,
124
+ ambiguous,
125
+ };
126
+ }
127
+ export function resolveCustomWebToolAlias(toolName, aliases) {
128
+ const direct = aliases?.get(toolName);
129
+ if (direct)
130
+ return direct;
131
+ for (const [alias, original] of aliases ?? []) {
132
+ if (toolName.endsWith(`_${alias}`))
133
+ return original;
134
+ }
135
+ return toolName;
136
+ }
68
137
  /**
69
138
  * Build the nested McpFileSystemOptions / McpMetaToolOptions shape used by
70
139
  * requestContext.#23 / #34. One `McpDescriptor` per resolved server (builtins
@@ -77,7 +146,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
77
146
  const order = [];
78
147
  const byServer = new Map();
79
148
  for (const t of tools) {
80
- const id = resolveToolServerIdentity(t.name, providerIdentifier, knownMcpServers);
149
+ const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
81
150
  let list = byServer.get(id.server);
82
151
  if (!list) {
83
152
  list = [];
@@ -85,7 +154,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
85
154
  order.push(id.server);
86
155
  }
87
156
  list.push({
88
- tool_name: id.toolName,
157
+ tool_name: t.sourceName ? t.name : id.toolName,
89
158
  description: t.description ?? "",
90
159
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
91
160
  });
@@ -104,31 +173,28 @@ export function buildLiveRequestContext(tools, providerIdentifier = "opencode",
104
173
  const flat = toolsToDescriptors(tools, providerIdentifier, knownMcpServers);
105
174
  const nested = toolsToMcpDescriptors(tools, providerIdentifier, knownMcpServers);
106
175
  const cwd = process.cwd();
107
- return {
108
- env: {
109
- os_version: process.platform,
110
- workspace_paths: [cwd],
111
- shell: process.env.SHELL || "/bin/bash",
112
- time_zone: "UTC",
113
- project_folder: cwd,
114
- process_working_directory: cwd,
115
- },
176
+ const ctx = {
177
+ env: buildEnv(cwd),
116
178
  tools: flat,
117
179
  mcp_file_system_options: {
118
180
  enabled: true,
119
- workspace_project_dir: cwd,
181
+ workspace_project_dir: ensureOpencodeProjectDir(cwd),
120
182
  mcp_descriptors: nested,
121
183
  },
122
184
  mcp_meta_tool_options: {
123
185
  enabled: true,
124
186
  mcp_descriptors: nested,
125
187
  },
188
+ web_search_enabled: false,
189
+ web_fetch_enabled: false,
126
190
  rules_info_complete: true,
127
191
  env_info_complete: true,
128
192
  repository_info_complete: true,
129
193
  mcp_file_system_info_complete: true,
130
194
  git_status_info_complete: true,
131
195
  };
196
+ traceRequestContextPaths("buildLiveRequestContext", ctx);
197
+ return ctx;
132
198
  }
133
199
  // ── Cursor exec-variant → opencode tool name ──
134
200
  // Native ExecServerMessage variants only (agent.v1). There is no edit_args or
@@ -235,16 +301,17 @@ export function parseExecServerMessage(msg) {
235
301
  if (!resultField)
236
302
  return undefined;
237
303
  const execId = msg.exec_id ?? "";
238
- // F11: Cursor native background shell → OpenCode bash. Emit the original
239
- // command for UI/storage; the classic plugin before-hook wraps with nohup.
240
- // Residual: the child can keep running after this tool call completes.
304
+ // F11: Cursor native background shell → OpenCode bash. Keep the wrapper
305
+ // self-contained so direct provider / hosts without shell.env still detach
306
+ // and return a PID. The classic plugin replaces this with its display-safe
307
+ // env or wrapper-file path before execution.
241
308
  if (execVariant === "background_shell_spawn_args") {
242
309
  const raw = msg.background_shell_spawn_args ?? {};
243
310
  const command = str(raw.command);
244
311
  const workingDirectory = str(raw.working_directory) ?? "";
245
312
  const args = {};
246
313
  if (command)
247
- args.command = command;
314
+ args.command = buildBackgroundShellCommand(command);
248
315
  if (workingDirectory)
249
316
  args.workdir = workingDirectory;
250
317
  return {
@@ -1112,6 +1179,7 @@ export function detectExecVariantField(agentServerPayload) {
1112
1179
  * Encode exec #10 request_context_result from a prebuilt RequestContext payload.
1113
1180
  */
1114
1181
  export function buildRequestContextResult(execId, requestContext) {
1182
+ traceRequestContextPaths(`buildRequestContextResult id=${execId}`, requestContext);
1115
1183
  return encodeMessage("AgentClientMessage", {
1116
1184
  exec_client_message: {
1117
1185
  id: execId,
@@ -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;
@@ -0,0 +1,126 @@
1
+ import { trace } from "./debug.js";
2
+ import { readAllFieldsStrict } from "./protocol/struct.js";
3
+ export class AttemptReplaySafety {
4
+ sessionId;
5
+ barrierReason;
6
+ constructor(sessionId) {
7
+ this.sessionId = sessionId;
8
+ }
9
+ markBarrier(reason) {
10
+ if (this.barrierReason)
11
+ return;
12
+ this.barrierReason = reason;
13
+ trace(`replay barrier: reason=${reason} sessionId=${this.sessionId}`);
14
+ }
15
+ applyTo(failure) {
16
+ failure.replaySafe = this.barrierReason === undefined && failure.replaySafe;
17
+ if (this.barrierReason) {
18
+ trace(`replay suppressed: reason=${this.barrierReason} sessionId=${this.sessionId}`);
19
+ }
20
+ return failure;
21
+ }
22
+ }
23
+ const INTERACTION_UPDATE_FIELDS = new Set([1, 2, 3, 4, 7, 13, 14, 16, 17]);
24
+ const INTERACTION_QUERY_FIELDS = new Set([2, 3, 4, 7, 8, 9, 10, 11, 12, 13, 14]);
25
+ const TOP_LEVEL_FIELDS = new Set([1, 2, 3, 4, 5, 7]);
26
+ function nestedFields(topLevel, field) {
27
+ if (topLevel?.fn !== field || topLevel.wt !== 2 || !topLevel.bytes)
28
+ return [];
29
+ return readAllFieldsStrict(topLevel.bytes) ?? [];
30
+ }
31
+ function analyzeExecWire(topLevel) {
32
+ const variants = nestedFields(topLevel, 2)
33
+ .filter((field) => ![1, 15, 19].includes(field.fn));
34
+ const exactVariant = (field) => variants.length === 1 && variants[0].fn === field && variants[0].wt === 2;
35
+ return {
36
+ exactRequestContext: exactVariant(10),
37
+ exactMcpState: exactVariant(36),
38
+ };
39
+ }
40
+ function validKvWire(topLevel) {
41
+ const variants = nestedFields(topLevel, 4).filter((field) => field.fn !== 1);
42
+ const variant = variants.length === 1 ? variants[0] : undefined;
43
+ const args = variant?.wt === 2 && variant.bytes
44
+ ? (readAllFieldsStrict(variant.bytes) ?? [])
45
+ : [];
46
+ const blobIds = args.filter((field) => field.fn === 1 && field.wt === 2 && (field.bytes?.length ?? 0) > 0);
47
+ return !!variant
48
+ && [2, 3].includes(variant.fn)
49
+ && variant.wt === 2
50
+ && blobIds.length === 1
51
+ && args.every((field) => field.wt === 2 && (field.fn === 1 || (variant.fn === 3 && field.fn === 2)));
52
+ }
53
+ function validInteractionUpdateWire(topLevel, decoded) {
54
+ if (!decoded)
55
+ return true;
56
+ const fields = nestedFields(topLevel, 1);
57
+ const update = fields.length === 1 ? fields[0] : undefined;
58
+ if (!update || update.wt !== 2 || !INTERACTION_UPDATE_FIELDS.has(update.fn))
59
+ return false;
60
+ if (![1, 4].includes(update.fn))
61
+ return true;
62
+ const delta = update.bytes ? (readAllFieldsStrict(update.bytes) ?? []) : [];
63
+ return delta.length === 1 && delta[0].fn === 1 && delta[0].wt === 2;
64
+ }
65
+ function validInteractionQueryWire(topLevel, decoded) {
66
+ if (!decoded)
67
+ return true;
68
+ const fields = nestedFields(topLevel, 7);
69
+ const ids = fields.filter((field) => field.fn === 1);
70
+ const variants = fields.filter((field) => field.fn !== 1);
71
+ return ids.length <= 1
72
+ && ids.every((field) => field.wt === 0)
73
+ && variants.length === 1
74
+ && variants[0].wt === 2
75
+ && INTERACTION_QUERY_FIELDS.has(variants[0].fn);
76
+ }
77
+ function decodedMatchesWire(topLevel, decoded) {
78
+ return !((topLevel?.fn === 1 && !decoded.interactionUpdate)
79
+ || (topLevel?.fn === 2 && !decoded.exec)
80
+ || (topLevel?.fn === 4 && !decoded.kv)
81
+ || (topLevel?.fn === 5 && !decoded.execControl)
82
+ || (topLevel?.fn === 7 && !decoded.interactionQuery));
83
+ }
84
+ function hasSemanticProgress(decoded) {
85
+ const update = decoded.interactionUpdate;
86
+ const text = update?.text_delta?.text;
87
+ const thinking = update?.thinking_delta?.text;
88
+ return (typeof text === "string" && text.length > 0)
89
+ || (typeof thinking === "string" && thinking.length > 0)
90
+ || !!update?.turn_ended
91
+ || !!update?.tool_call_started
92
+ || !!update?.tool_call_completed
93
+ || !!decoded.exec
94
+ || !!decoded.kv
95
+ || !!decoded.execControl
96
+ || !!decoded.interactionQuery
97
+ || !!decoded.checkpointBytes?.length;
98
+ }
99
+ /** Classify one decoded server frame without performing any protocol side effects. */
100
+ export function analyzeReplayFrame(payload, decoded) {
101
+ const topLevelFields = readAllFieldsStrict(payload) ?? [];
102
+ const topLevel = topLevelFields.length === 1 ? topLevelFields[0] : undefined;
103
+ const exec = analyzeExecWire(topLevel);
104
+ const validKv = validKvWire(topLevel);
105
+ const malformed = !topLevel
106
+ || topLevel.wt !== 2
107
+ || !TOP_LEVEL_FIELDS.has(topLevel.fn)
108
+ || !validInteractionUpdateWire(topLevel, decoded.interactionUpdate)
109
+ || !validInteractionQueryWire(topLevel, decoded.interactionQuery)
110
+ || !decodedMatchesWire(topLevel, decoded)
111
+ || !!decoded.execControl;
112
+ let barrier;
113
+ if (decoded.interactionUpdate?.tool_call_started || decoded.interactionUpdate?.tool_call_completed) {
114
+ barrier = "display-tool-lifecycle";
115
+ }
116
+ else if (malformed || (topLevel.fn === 4 && !validKv)) {
117
+ barrier = "unknown-or-malformed-frame";
118
+ }
119
+ else if (topLevel.fn === 2 && !exec.exactRequestContext && !exec.exactMcpState) {
120
+ barrier = "non-control-exec";
121
+ }
122
+ return {
123
+ semanticProgress: hasSemanticProgress(decoded),
124
+ barrier,
125
+ };
126
+ }
package/dist/session.d.ts CHANGED
@@ -1,6 +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
5
  export type Frame = {
5
6
  flags: number;
6
7
  payload: Uint8Array;
@@ -117,6 +118,8 @@ export type CursorSession = {
117
118
  blobs: Map<string, Uint8Array>;
118
119
  /** McpToolDefinition list advertised this turn — echoed into the request_context reply. */
119
120
  toolDescriptors: Array<Record<string, unknown>>;
121
+ /** Cursor-facing web alias → exact executable host tool for this Run. */
122
+ toolAliases?: ToolAliasRegistry;
120
123
  /** Full RequestContext for exec #10 replies. */
121
124
  requestContext: Record<string, unknown>;
122
125
  /**
@@ -27,6 +27,13 @@ export type CursorShellOutcome = {
27
27
  msToWait: number;
28
28
  reason: 1;
29
29
  };
30
+ /** Track OpenCode's configured shell from the classic config hook. */
31
+ export declare function setCursorShellPath(shell: string | undefined): void;
32
+ /**
33
+ * Mirror the relevant part of OpenCode Shell.acceptable(): fish/nu are denied,
34
+ * then POSIX falls back to bash when installed and `/bin/sh` otherwise.
35
+ */
36
+ export declare function resolveCursorShellKind(shell?: string | undefined): "bash" | "zsh" | "sh" | "dash" | "other";
30
37
  export declare function shellPolicyFromMetadata(metadata: Record<string, unknown> | undefined): CursorShellPolicy | undefined;
31
38
  /** Register a Cursor shell request before OpenCode executes its emitted tool call. */
32
39
  export declare function registerCursorShellCall(toolCallId: string, metadata: Record<string, unknown> | undefined): void;
@@ -58,15 +65,13 @@ export declare function buildBackgroundShellCommand(command: string): string;
58
65
  /**
59
66
  * Prepare OpenCode Bash args before execution when Cursor requested wrapping.
60
67
  *
61
- * Important: do **not** replace `args.command` with the wrapper script.
62
- * OpenCode's bash UI renders `state.input.command`, and `ctx.metadata()`
63
- * persists the execute-time `args` object into that field. Mutating
64
- * `args.command` therefore leaks the private wrapper into the TUI/GUI.
68
+ * bash/zsh source the shell.env injector, so the original command remains in
69
+ * OpenCode's permission/UI state. sh/dash ignore those startup variables; for
70
+ * them, use a short `exec wrapper.sh` command that contains no user payload.
65
71
  *
66
- * Wrapping is applied later via {@link cursorShellEnvForCall} (`shell.env`),
67
- * which uses BASH_ENV / ZDOTDIR injectors so bash/zsh `-c <original>` is
68
- * replaced with the wrapper while the stored/displayed command stays original.
69
- * Permissions also keep analyzing the real user command.
72
+ * background_shell_spawn may already contain the inline non-plugin fallback.
73
+ * The classic hook replaces it with the original command (bash/zsh) or the
74
+ * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
70
75
  */
71
76
  export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
72
77
  /** Restore the model-facing command in OpenCode's completed tool title. */
@@ -74,8 +79,8 @@ export declare function cursorShellOriginalCommand(toolCallId: string): string |
74
79
  /** Drop injector temp files for a finished/abandoned Cursor shell call. */
75
80
  export declare function releaseCursorShellEnv(toolCallId: string): void;
76
81
  /**
77
- * Env vars for OpenCode's `shell.env` hook so bash/zsh execute the Cursor
78
- * wrapper while `args.command` (and therefore the bash UI) stay original.
82
+ * Env vars for OpenCode's shell.env hook. bash/zsh execute the injector; the
83
+ * same materialized wrapper backs the direct-command sh/dash fallback.
79
84
  */
80
85
  export declare function cursorShellEnvForCall(toolCallId: string | undefined): Record<string, string> | undefined;
81
86
  /**