cursor-opencode-provider 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +52 -180
  2. package/dist/compaction-marker.d.ts +3 -2
  3. package/dist/compaction-marker.js +3 -2
  4. package/dist/host-agent-mode.d.ts +32 -0
  5. package/dist/host-agent-mode.js +92 -0
  6. package/dist/language-model.d.ts +10 -1
  7. package/dist/language-model.js +253 -41
  8. package/dist/models.d.ts +12 -1
  9. package/dist/models.js +16 -6
  10. package/dist/opencode2/catalog.d.ts +46 -11
  11. package/dist/opencode2/catalog.js +60 -45
  12. package/dist/opencode2/todo-tools.d.ts +64 -0
  13. package/dist/opencode2/todo-tools.js +165 -0
  14. package/dist/opencode2/types.d.ts +138 -83
  15. package/dist/opencode2/types.js +8 -10
  16. package/dist/plan-execution-kickoff.d.ts +2 -0
  17. package/dist/plan-execution-kickoff.js +25 -1
  18. package/dist/plugin-opencode2.d.ts +4 -1
  19. package/dist/plugin-opencode2.js +335 -154
  20. package/dist/plugin.js +5 -1
  21. package/dist/protocol/conversation-persistence.d.ts +2 -0
  22. package/dist/protocol/conversation-persistence.js +19 -1
  23. package/dist/protocol/conversation-state.d.ts +4 -0
  24. package/dist/protocol/conversation-state.js +4 -0
  25. package/dist/protocol/messages.js +14 -1
  26. package/dist/protocol/switch-mode.d.ts +5 -4
  27. package/dist/protocol/switch-mode.js +12 -4
  28. package/dist/protocol/tool-call-bridge.d.ts +29 -1
  29. package/dist/protocol/tool-call-bridge.js +187 -7
  30. package/dist/protocol/tools.d.ts +30 -4
  31. package/dist/protocol/tools.js +185 -58
  32. package/dist/session.d.ts +13 -1
  33. package/dist/session.js +1 -0
  34. package/dist/shared.d.ts +3 -1
  35. package/dist/shared.js +3 -1
  36. package/dist/shell-timeout.d.ts +13 -0
  37. package/dist/shell-timeout.js +38 -0
  38. package/dist/todo-store.d.ts +29 -0
  39. package/dist/todo-store.js +78 -0
  40. package/dist/web-tools.d.ts +13 -0
  41. package/dist/web-tools.js +83 -5
  42. package/package.json +6 -3
package/dist/plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
- import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
3
+ import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION, CURSOR_HOST_AGENT_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, } from "./shared.js";
4
4
  import { cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled } from "./plugin-core.js";
5
5
  import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
6
6
  import { readCache, discoverModels, isCacheFresh } from "./models.js";
@@ -254,6 +254,10 @@ export async function CursorPlugin(input) {
254
254
  async "chat.params"(hookInput, output) {
255
255
  if (hookInput.model.providerID !== CURSOR_PROVIDER_ID)
256
256
  return;
257
+ // Agent changes can replace the host system prompt and mode contract.
258
+ // Carry the canonical OpenCode id so an incompatible Cursor checkpoint
259
+ // is rotated instead of resuming the prior agent's prompt.
260
+ output.options[CURSOR_HOST_AGENT_OPTION] = hookInput.agent;
257
261
  // OpenCode's compaction pipeline invokes the LLM with agent="compaction".
258
262
  // Carry that stable runtime fact into LanguageModelV3 providerOptions so
259
263
  // the provider never has to guess from an empty tool list.
@@ -9,6 +9,8 @@ export type PersistedConversation = {
9
9
  requestContext: Record<string, unknown>;
10
10
  toolCatalog: OpencodeToolDef[];
11
11
  postCompactionRebase: boolean;
12
+ hostAgent?: string;
13
+ systemPromptHash?: string;
12
14
  };
13
15
  export type ConversationLoadStatus = "restored" | "missing" | "invalid" | "expired";
14
16
  export type PersistedConversationLoad = {
@@ -47,7 +47,7 @@ function cloneConversation(value) {
47
47
  *
48
48
  * ConversationCache: schema_version=1, session_key=2, conversation_id=3,
49
49
  * updated_at=4, checkpoint=5, blobs=6, request_context=7, tool_catalog=8,
50
- * post_compaction_rebase=9.
50
+ * post_compaction_rebase=9, host_agent=10, system_prompt_hash=11.
51
51
  * Blob: id=1, data=2. Tool: name=1, description=2,
52
52
  * input_schema_json=3, source_name=4.
53
53
  *
@@ -172,6 +172,10 @@ function encodeCacheFile(value) {
172
172
  }
173
173
  if (value.postCompactionRebase)
174
174
  writer.uint32(fieldTag(9, 0)).bool(true);
175
+ if (value.hostAgent)
176
+ writer.uint32(fieldTag(10, 2)).string(value.hostAgent);
177
+ if (value.systemPromptHash)
178
+ writer.uint32(fieldTag(11, 2)).string(value.systemPromptHash);
175
179
  return { protobufBytes: writer.finish(), requestContextBytes: requestContext.length };
176
180
  }
177
181
  function decodeProtobuf(data) {
@@ -185,6 +189,8 @@ function decodeProtobuf(data) {
185
189
  let requestContextBytes;
186
190
  const toolCatalog = [];
187
191
  let postCompactionRebase = false;
192
+ let hostAgent;
193
+ let systemPromptHash;
188
194
  while (reader.pos < reader.len) {
189
195
  const tag = reader.uint32();
190
196
  const wireType = tag & 7;
@@ -234,6 +240,16 @@ function decodeProtobuf(data) {
234
240
  throw new Error("invalid compaction marker");
235
241
  postCompactionRebase = reader.bool();
236
242
  break;
243
+ case 10:
244
+ if (wireType !== 2)
245
+ throw new Error("invalid host agent");
246
+ hostAgent = reader.string();
247
+ break;
248
+ case 11:
249
+ if (wireType !== 2)
250
+ throw new Error("invalid system prompt hash");
251
+ systemPromptHash = reader.string();
252
+ break;
237
253
  default:
238
254
  reader.skipType(wireType);
239
255
  }
@@ -252,6 +268,8 @@ function decodeProtobuf(data) {
252
268
  requestContext,
253
269
  toolCatalog,
254
270
  postCompactionRebase,
271
+ ...(hostAgent ? { hostAgent } : {}),
272
+ ...(systemPromptHash ? { systemPromptHash } : {}),
255
273
  };
256
274
  }
257
275
  function decodeCacheFile(compressed, expectedSessionKey) {
@@ -4,6 +4,8 @@ export declare function hydrateConversationState(cacheDir: string, sessionKey: s
4
4
  conversationId: string;
5
5
  postCompactionRebase: boolean;
6
6
  toolCatalog: OpencodeToolDef[];
7
+ hostAgent?: string;
8
+ systemPromptHash?: string;
7
9
  } | undefined>;
8
10
  /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
9
11
  export declare function persistConversationState(cacheDir: string, input: {
@@ -12,5 +14,7 @@ export declare function persistConversationState(cacheDir: string, input: {
12
14
  requestContext: Record<string, unknown>;
13
15
  toolCatalog?: OpencodeToolDef[];
14
16
  postCompactionRebase?: boolean;
17
+ hostAgent?: string;
18
+ systemPromptHash?: string;
15
19
  }): Promise<void>;
16
20
  export declare function clearPersistedConversationState(cacheDir: string, sessionKey: string, expectedConversationId?: string): Promise<void>;
@@ -27,6 +27,8 @@ export async function hydrateConversationState(cacheDir, sessionKey) {
27
27
  conversationId: persisted.conversationId,
28
28
  postCompactionRebase: persisted.postCompactionRebase,
29
29
  toolCatalog: structuredClone(persisted.toolCatalog),
30
+ ...(persisted.hostAgent ? { hostAgent: persisted.hostAgent } : {}),
31
+ ...(persisted.systemPromptHash ? { systemPromptHash: persisted.systemPromptHash } : {}),
30
32
  };
31
33
  }
32
34
  /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
@@ -50,6 +52,8 @@ export async function persistConversationState(cacheDir, input) {
50
52
  requestContext,
51
53
  toolCatalog: structuredClone(input.toolCatalog ?? []),
52
54
  postCompactionRebase: input.postCompactionRebase,
55
+ hostAgent: input.hostAgent,
56
+ systemPromptHash: input.systemPromptHash,
53
57
  });
54
58
  trace(`conversation persistence: saved sessionKey=${input.sessionKey} ` +
55
59
  `conversationId=${input.conversationId} checkpoint=${checkpoint?.length ?? 0}B ` +
@@ -257,11 +257,24 @@ export function createMessageTypes() {
257
257
  addType(root, "PiFindToolCall", [{ id: 1, name: "args", type: "PiFindToolArgs" }]);
258
258
  addType(root, "PiLsToolCall", [{ id: 1, name: "args", type: "PiLsToolArgs" }]);
259
259
  // Display-only native tools Cursor may complete without an ExecServerMessage.
260
+ // ReadTodos mirrors UpdateTodos: args + result (see agent.v1 ReadTodosToolCall).
260
261
  addType(root, "ReadTodosArgs", [
261
262
  { id: 1, name: "status_filter", type: "TodoStatus", repeated: true },
262
263
  { id: 2, name: "id_filter", type: "string", repeated: true },
263
264
  ]);
264
- addType(root, "ReadTodosToolCall", [{ id: 1, name: "args", type: "ReadTodosArgs" }]);
265
+ addType(root, "ReadTodosSuccess", [
266
+ { id: 1, name: "todos", type: "TodoItem", repeated: true },
267
+ { id: 2, name: "total_count", type: "int32" },
268
+ ]);
269
+ addType(root, "ReadTodosError", [{ id: 1, name: "error", type: "string" }]);
270
+ addType(root, "ReadTodosResult", [
271
+ { id: 1, name: "success", type: "ReadTodosSuccess" },
272
+ { id: 2, name: "error", type: "ReadTodosError" },
273
+ ], [{ name: "result", fields: ["success", "error"] }]);
274
+ addType(root, "ReadTodosToolCall", [
275
+ { id: 1, name: "args", type: "ReadTodosArgs" },
276
+ { id: 2, name: "result", type: "ReadTodosResult" },
277
+ ]);
265
278
  addType(root, "AwaitArgs", [
266
279
  { id: 1, name: "task_id", type: "string" },
267
280
  { id: 2, name: "block_until_ms", type: "uint32" },
@@ -8,7 +8,7 @@
8
8
  * Display `switch_mode_tool_call` (#25) is a separate transcript record and is
9
9
  * not replayed as a host tool.
10
10
  *
11
- * Host mapping (advertisement-gated):
11
+ * OpenCode 1.x mapping (advertisement-gated):
12
12
  * - plan, spec → plan_enter
13
13
  * - every other non-empty target → plan_exit (leave plan for OpenCode build),
14
14
  * then inject the Cursor CLI-shaped mode reminder for that target
@@ -19,9 +19,10 @@
19
19
  * `setAgentModel` primary-agent switch (opencode `tool/plan.ts`,
20
20
  * `session/prompt.ts`). A provider cannot reach `setAgentModel`, but the two
21
21
  * observable halves — the approval gate and the behavioural contract — are both
22
- * reachable, so the bridge degrades instead of refusing when the host tool is
23
- * missing (`plan_enter` is commented out upstream and `plan_exit` is gated
24
- * behind OPENCODE_EXPERIMENTAL_PLAN_MODE + CLI client):
22
+ * reachable, so the protocol bridge degrades instead of refusing when the host
23
+ * tool is missing. OpenCode 2 additionally exposes `session.switchAgent`; its
24
+ * entrypoint installs a structural callback that selects the native `plan` or
25
+ * `build` primary agent after the owning Cursor Run becomes terminal:
25
26
  *
26
27
  * - entering plan/spec needs no host tool at all — approve immediately and let
27
28
  * the injected <system_reminder> carry the contract, exactly as Cursor CLI's
@@ -8,7 +8,7 @@
8
8
  * Display `switch_mode_tool_call` (#25) is a separate transcript record and is
9
9
  * not replayed as a host tool.
10
10
  *
11
- * Host mapping (advertisement-gated):
11
+ * OpenCode 1.x mapping (advertisement-gated):
12
12
  * - plan, spec → plan_enter
13
13
  * - every other non-empty target → plan_exit (leave plan for OpenCode build),
14
14
  * then inject the Cursor CLI-shaped mode reminder for that target
@@ -19,9 +19,10 @@
19
19
  * `setAgentModel` primary-agent switch (opencode `tool/plan.ts`,
20
20
  * `session/prompt.ts`). A provider cannot reach `setAgentModel`, but the two
21
21
  * observable halves — the approval gate and the behavioural contract — are both
22
- * reachable, so the bridge degrades instead of refusing when the host tool is
23
- * missing (`plan_enter` is commented out upstream and `plan_exit` is gated
24
- * behind OPENCODE_EXPERIMENTAL_PLAN_MODE + CLI client):
22
+ * reachable, so the protocol bridge degrades instead of refusing when the host
23
+ * tool is missing. OpenCode 2 additionally exposes `session.switchAgent`; its
24
+ * entrypoint installs a structural callback that selects the native `plan` or
25
+ * `build` primary agent after the owning Cursor Run becomes terminal:
25
26
  *
26
27
  * - entering plan/spec needs no host tool at all — approve immediately and let
27
28
  * the injected <system_reminder> carry the contract, exactly as Cursor CLI's
@@ -68,6 +69,7 @@ export const SWITCH_MODE_EXIT_HEADER = "Build Agent";
68
69
  const SWITCH_MODE_EXIT_YES = "Yes";
69
70
  const SWITCH_MODE_EXIT_NO = "No";
70
71
  const activeCursorModeBySession = new Map();
72
+ const MAX_ACTIVE_CURSOR_MODES = 256;
71
73
  function asRecord(value) {
72
74
  return value && typeof value === "object" && !Array.isArray(value)
73
75
  ? value
@@ -248,6 +250,12 @@ export function setActiveCursorMode(sessionKey, targetModeId, options = {}) {
248
250
  firstTurn: true,
249
251
  bridgedPlanEntered: options.bridgedPlanEntered === true,
250
252
  });
253
+ while (activeCursorModeBySession.size > MAX_ACTIVE_CURSOR_MODES) {
254
+ const oldest = activeCursorModeBySession.keys().next().value;
255
+ if (!oldest)
256
+ break;
257
+ activeCursorModeBySession.delete(oldest);
258
+ }
251
259
  }
252
260
  export function getActiveCursorMode(sessionKey) {
253
261
  if (!sessionKey)
@@ -19,10 +19,38 @@ export type BridgedOpenCodeToolCall = {
19
19
  callId: string;
20
20
  variant: string;
21
21
  };
22
+ /**
23
+ * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
24
+ * undefined when the input is not a todo array (nothing to learn). Drops the
25
+ * synthetic "plan" id minted for create_plan mirrors so later merges never
26
+ * resurrect it.
27
+ */
28
+ export declare function snapshotMirroredTodos(todos: unknown): Array<Record<string, unknown>> | undefined;
29
+ /**
30
+ * Refresh the mirrored snapshot from a host `todoread` tool result. The host
31
+ * list is authoritative; returns undefined when the output is not recognizable
32
+ * todo JSON (prose, caps, errors) so a stale-but-useful snapshot survives.
33
+ */
34
+ export declare function snapshotMirroredTodosFromReadOutput(output: string): Array<Record<string, unknown>> | undefined;
35
+ /**
36
+ * Overlay Cursor merge patches onto a previously mirrored full todo list.
37
+ * Preserves prior order; appends newly introduced ids in patch order.
38
+ *
39
+ * Matching is by id first, then by exact (trimmed) content. The content
40
+ * fallback covers mixed flows where the prior snapshot came from a direct
41
+ * host `todowrite` (positional ids) while the patch references Cursor-side
42
+ * ids for the same tasks. Patch items with neither an id/content match nor
43
+ * their own content are skipped — a status-only update for an unknown item
44
+ * cannot be mapped onto the host list.
45
+ */
46
+ export declare function applyTodoMerge(prior: ReadonlyArray<Record<string, unknown>>, patch: unknown): Array<Record<string, unknown>>;
22
47
  /**
23
48
  * Decode a Cursor ToolCall oneof into a display tool call we can bridge.
49
+ *
50
+ * @param priorMirroredTodos Last full todo snapshot mirrored this Run; used to
51
+ * expand Cursor `merge: true` updates that omit ToolCallCompleted.success.todos.
24
52
  */
25
- export declare function parseDisplayToolCall(callId: string, toolCall: Record<string, unknown> | undefined): DisplayToolCall | undefined;
53
+ export declare function parseDisplayToolCall(callId: string, toolCall: Record<string, unknown> | undefined, priorMirroredTodos?: ReadonlyArray<Record<string, unknown>>): DisplayToolCall | undefined;
26
54
  /**
27
55
  * Pick an advertised OpenCode tool for a display call, remapping args as needed.
28
56
  * Returns undefined when nothing compatible is advertised this turn.
@@ -1,4 +1,4 @@
1
- import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName } from "./tools.js";
1
+ import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
2
2
  import { decodeStructEntriesToJson } from "./struct.js";
3
3
  import { mapSwitchModeTarget } from "./switch-mode.js";
4
4
  const TODO_STATUS = {
@@ -46,11 +46,13 @@ const VARIANT_TO_OPENCODE = {
46
46
  const TOOL_CALL_VARIANTS = Object.keys(VARIANT_TO_OPENCODE);
47
47
  // ToolCallCompleted is a notification, not an execution request. Only mirror
48
48
  // client-visible state whose authoritative final value is already present in
49
- // the completed payload. Data-returning, interactive, and side-effecting calls
49
+ // the completed payload (or reconstructable from a prior mirrored snapshot for
50
+ // Cursor todo merges). Data-returning, interactive, and side-effecting calls
50
51
  // must use an exec/interaction request channel where Cursor can receive their
51
52
  // actual result.
52
53
  const DISPLAY_STATE_MIRROR_VARIANTS = new Set([
53
54
  "update_todos_tool_call",
55
+ "read_todos_tool_call",
54
56
  "create_plan_tool_call",
55
57
  ]);
56
58
  function asRecord(v) {
@@ -97,14 +99,169 @@ function mapTodos(raw) {
97
99
  };
98
100
  });
99
101
  }
102
+ /**
103
+ * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
104
+ * undefined when the input is not a todo array (nothing to learn). Drops the
105
+ * synthetic "plan" id minted for create_plan mirrors so later merges never
106
+ * resurrect it.
107
+ */
108
+ export function snapshotMirroredTodos(todos) {
109
+ if (!Array.isArray(todos))
110
+ return undefined;
111
+ return mapTodos(todos).filter((t) => {
112
+ const id = typeof t.id === "string" ? t.id : "";
113
+ return id.length > 0 && id !== "plan";
114
+ });
115
+ }
116
+ /**
117
+ * Refresh the mirrored snapshot from a host `todoread` tool result. The host
118
+ * list is authoritative; returns undefined when the output is not recognizable
119
+ * todo JSON (prose, caps, errors) so a stale-but-useful snapshot survives.
120
+ */
121
+ export function snapshotMirroredTodosFromReadOutput(output) {
122
+ let parsed;
123
+ try {
124
+ parsed = JSON.parse(output);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ const list = Array.isArray(parsed) ? parsed : (asRecord(parsed)?.todos ?? undefined);
130
+ if (!Array.isArray(list))
131
+ return undefined;
132
+ return snapshotMirroredTodos(list);
133
+ }
134
+ /**
135
+ * Overlay Cursor merge patches onto a previously mirrored full todo list.
136
+ * Preserves prior order; appends newly introduced ids in patch order.
137
+ *
138
+ * Matching is by id first, then by exact (trimmed) content. The content
139
+ * fallback covers mixed flows where the prior snapshot came from a direct
140
+ * host `todowrite` (positional ids) while the patch references Cursor-side
141
+ * ids for the same tasks. Patch items with neither an id/content match nor
142
+ * their own content are skipped — a status-only update for an unknown item
143
+ * cannot be mapped onto the host list.
144
+ */
145
+ export function applyTodoMerge(prior, patch) {
146
+ const rawPatch = Array.isArray(patch) ? patch : [];
147
+ const mappedPatch = mapTodos(patch);
148
+ const byId = new Map();
149
+ const byContent = new Map();
150
+ // Patch ids already folded into a prior record via content match: never
151
+ // re-emit them as new items in the output phase.
152
+ const consumedPatchIds = new Set();
153
+ // Patch ids accepted as genuinely new content: the only patch ids the
154
+ // output phase may append. Orphans skipped above (unknown id, no content)
155
+ // must not leak back in here.
156
+ const appendedPatchIds = new Set();
157
+ for (const item of prior) {
158
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : "";
159
+ if (!id)
160
+ continue;
161
+ const normalized = {
162
+ id,
163
+ content: typeof item.content === "string" ? item.content : "",
164
+ status: typeof item.status === "string" ? item.status : mapTodoStatus(item.status),
165
+ priority: typeof item.priority === "string" && item.priority ? item.priority : "medium",
166
+ };
167
+ byId.set(id, normalized);
168
+ const key = normalized.content.trim();
169
+ if (key && !byContent.has(key))
170
+ byContent.set(key, normalized);
171
+ }
172
+ const overlay = (target, item, raw) => {
173
+ // Only overwrite fields the patch actually carries. mapTodos fills
174
+ // defaults ("" content, "pending" status, "medium" priority) that must not
175
+ // clobber a prior item when Cursor sends an id+status-only patch.
176
+ const next = { ...target, id: target.id };
177
+ if (typeof raw.content === "string" && raw.content.length > 0) {
178
+ next.content = raw.content;
179
+ }
180
+ if (raw.status !== undefined && raw.status !== null) {
181
+ next.status = String(item.status);
182
+ }
183
+ if (typeof raw.priority === "string" && raw.priority) {
184
+ next.priority = raw.priority;
185
+ }
186
+ return next;
187
+ };
188
+ for (let index = 0; index < mappedPatch.length; index++) {
189
+ const item = mappedPatch[index];
190
+ const raw = asRecord(rawPatch[index]) ?? {};
191
+ const id = String(item.id);
192
+ const byIdMatch = byId.get(id);
193
+ if (byIdMatch) {
194
+ const merged = overlay(byIdMatch, item, raw);
195
+ byId.set(id, merged);
196
+ // Keep the content index pointing at the fresh record so later
197
+ // content-fallback matches never merge onto a stale copy.
198
+ const oldKey = typeof byIdMatch.content === "string" ? byIdMatch.content.trim() : "";
199
+ const newKey = typeof merged.content === "string" ? merged.content.trim() : "";
200
+ if (oldKey && byContent.get(oldKey) === byIdMatch)
201
+ byContent.delete(oldKey);
202
+ if (newKey && !byContent.has(newKey))
203
+ byContent.set(newKey, merged);
204
+ continue;
205
+ }
206
+ const contentKey = typeof raw.content === "string" && raw.content.trim().length > 0
207
+ ? raw.content.trim()
208
+ : "";
209
+ const byContentMatch = contentKey ? byContent.get(contentKey) : undefined;
210
+ if (byContentMatch) {
211
+ // Same task, different id space (host positional ids vs Cursor ids).
212
+ // Keep the prior id so snapshot identity stays stable and order is
213
+ // preserved; adopt the patch's status/priority/content.
214
+ const merged = overlay(byContentMatch, item, raw);
215
+ byId.set(String(byContentMatch.id), merged);
216
+ if (contentKey)
217
+ byContent.set(contentKey, merged);
218
+ consumedPatchIds.add(id);
219
+ continue;
220
+ }
221
+ if (!contentKey)
222
+ continue;
223
+ byId.set(id, item);
224
+ byContent.set(contentKey, item);
225
+ appendedPatchIds.add(id);
226
+ }
227
+ const out = [];
228
+ const seen = new Set();
229
+ for (const item of prior) {
230
+ const id = typeof item.id === "string" ? item.id : "";
231
+ if (!id || seen.has(id))
232
+ continue;
233
+ const next = byId.get(id);
234
+ if (next) {
235
+ out.push(next);
236
+ seen.add(id);
237
+ }
238
+ }
239
+ for (const item of mappedPatch) {
240
+ const id = String(item.id);
241
+ // Id-matched items are already emitted under the prior id; content-matched
242
+ // items were folded into the prior record (not appended under the foreign
243
+ // patch id); orphans were skipped outright. Only genuinely new content
244
+ // reaches the host list.
245
+ if (seen.has(id) || consumedPatchIds.has(id))
246
+ continue;
247
+ if (!appendedPatchIds.has(id))
248
+ continue;
249
+ out.push(item);
250
+ seen.add(id);
251
+ }
252
+ return out;
253
+ }
100
254
  function unwrapArgs(variantPayload) {
101
255
  const nested = asRecord(variantPayload.args);
102
256
  return nested ?? variantPayload;
103
257
  }
104
258
  /**
105
259
  * Decode a Cursor ToolCall oneof into a display tool call we can bridge.
260
+ *
261
+ * @param priorMirroredTodos Last full todo snapshot mirrored this Run; used to
262
+ * expand Cursor `merge: true` updates that omit ToolCallCompleted.success.todos.
106
263
  */
107
- export function parseDisplayToolCall(callId, toolCall) {
264
+ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
108
265
  if (!toolCall || !callId)
109
266
  return undefined;
110
267
  const variant = findToolVariant(toolCall);
@@ -136,10 +293,16 @@ export function parseDisplayToolCall(callId, toolCall) {
136
293
  const success = asRecord(result?.success);
137
294
  const completedTodos = Array.isArray(success?.todos) ? success.todos : undefined;
138
295
  const isMerge = variant === "update_todos_tool_call" && args.merge === true;
139
- // OpenCode todowrite replaces the whole list. For Cursor merge updates,
140
- // only bridge when ToolCallCompleted includes the final merged list.
296
+ // OpenCode todowrite replaces the whole list. Prefer the completed payload;
297
+ // otherwise a non-merge args list; otherwise merge against the Run snapshot.
141
298
  const sourceTodos = completedTodos ?? (!isMerge ? args.todos : undefined);
142
- const todos = mapTodos(sourceTodos);
299
+ const canMergeFromPrior = sourceTodos === undefined &&
300
+ isMerge &&
301
+ Array.isArray(priorMirroredTodos) &&
302
+ priorMirroredTodos.length > 0;
303
+ const todos = canMergeFromPrior
304
+ ? applyTodoMerge(priorMirroredTodos, args.todos)
305
+ : mapTodos(sourceTodos);
143
306
  if (variant === "create_plan_tool_call") {
144
307
  const overview = typeof args.overview === "string" ? args.overview.trim() : "";
145
308
  const plan = typeof args.plan === "string" ? args.plan.trim() : "";
@@ -158,7 +321,21 @@ export function parseDisplayToolCall(callId, toolCall) {
158
321
  variant,
159
322
  preferredToolName: "todowrite",
160
323
  args: { todos },
161
- bridgeable: variant === "create_plan_tool_call" ? todos.length > 0 : Array.isArray(sourceTodos),
324
+ bridgeable: variant === "create_plan_tool_call"
325
+ ? todos.length > 0
326
+ : Array.isArray(sourceTodos) || canMergeFromPrior,
327
+ };
328
+ }
329
+ // Cursor TodoRead is the same display-only channel as TodoWrite (agent.v1
330
+ // read_todos_tool_call). OpenCode todoread takes an empty body; host filters
331
+ // are not part of the canonical schema, so drop status_filter / id_filter.
332
+ if (variant === "read_todos_tool_call") {
333
+ return {
334
+ callId,
335
+ variant,
336
+ preferredToolName: "todoread",
337
+ args: {},
338
+ bridgeable: true,
162
339
  };
163
340
  }
164
341
  if (variant === "edit_tool_call") {
@@ -295,6 +472,9 @@ export function resolveBridgedOpenCodeToolCall(display, advertised) {
295
472
  todos: mapTodos(display.args.todos ?? mapped.args.todos),
296
473
  };
297
474
  }
475
+ if (toolName === "todoread") {
476
+ mapped.args = {};
477
+ }
298
478
  return {
299
479
  toolName: mapped.toolName,
300
480
  args: mapped.args,
@@ -8,6 +8,25 @@ export type OpencodeToolDef = {
8
8
  /** Original flattened identity when `name` is a Cursor-facing alias. */
9
9
  sourceName?: string;
10
10
  };
11
+ /** Host file-tool argument key. OpenCode 1.x uses `filePath`; 2.0 uses `path`. */
12
+ export type HostFilePathKey = "path" | "filePath";
13
+ /** Host shell tool id. OpenCode 1.x uses `bash`; 2.0 uses `shell`. */
14
+ export type HostShellTool = "bash" | "shell";
15
+ export type HostToolDialect = {
16
+ filePathKey: HostFilePathKey;
17
+ shellTool: HostShellTool;
18
+ };
19
+ export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
20
+ /** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
21
+ export declare function opencodePathArg(args: Record<string, unknown> | undefined): string | undefined;
22
+ /**
23
+ * Infer the host tool dialect from advertised AI SDK schemas.
24
+ * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
25
+ */
26
+ export declare function hostToolDialectFromTools(tools: readonly {
27
+ name?: string;
28
+ inputSchema?: unknown;
29
+ }[]): HostToolDialect;
11
30
  export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
12
31
  export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
13
32
  export declare const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
@@ -72,9 +91,9 @@ export type HostSubagentDefinition = {
72
91
  description?: string;
73
92
  };
74
93
  export type HostSubagentCatalog = {
75
- executor?: "task";
94
+ executor?: "task" | "subagent";
76
95
  agents: HostSubagentDefinition[];
77
- /** True when the OpenCode task tool supplied its complete, permission-filtered catalog. */
96
+ /** True when the OpenCode task/subagent tool supplied its complete, permission-filtered catalog. */
78
97
  complete: boolean;
79
98
  };
80
99
  /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
@@ -143,13 +162,13 @@ export type ParsedExecRequest = {
143
162
  * this provider's warning text.
144
163
  */
145
164
  export declare function rejectPartialReadMutation(parsed: ParsedExecRequest): void;
146
- export declare function parseExecServerMessage(msg: Record<string, unknown>): ParsedExecRequest | undefined;
165
+ export declare function parseExecServerMessage(msg: Record<string, unknown>, dialect?: HostToolDialect): ParsedExecRequest | undefined;
147
166
  /**
148
167
  * Remap Cursor exec / MCP arg shapes onto OpenCode's Effect Schema keys.
149
168
  * Without this, OpenCode rejects calls with InvalidArgumentsError
150
169
  * (e.g. `path` instead of `filePath`, `file_text` instead of `content`).
151
170
  */
152
- export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<string, unknown>, execVariant?: string): {
171
+ export declare function mapCursorArgsToOpencode(toolName: string, raw: Record<string, unknown>, execVariant?: string, dialect?: HostToolDialect): {
153
172
  toolName: string;
154
173
  args: Record<string, unknown>;
155
174
  binaryBytes?: Uint8Array;
@@ -174,6 +193,13 @@ export declare function binaryWritePayload(parsed: ParsedExecRequest): {
174
193
  path: string;
175
194
  data: Uint8Array;
176
195
  } | undefined;
196
+ /**
197
+ * Replace the prompt-derived task title with Cursor's TaskToolCall description
198
+ * when the display call is correlated via tool_call_id. SubagentArgs itself has
199
+ * no description field, so without this OpenCode shows a cut-off first-five-words
200
+ * slice of the prompt.
201
+ */
202
+ export declare function preferCorrelatedTaskDescription(parsed: ParsedExecRequest, description: string | undefined): void;
177
203
  /**
178
204
  * Map Cursor McpArgs back to the OpenCode tool id.
179
205
  * Prefers provider_identifier + bare tool_name (github + create_pull_request