cursor-opencode-provider 0.6.8 → 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 (39) hide show
  1. package/README.md +50 -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 +6 -1
  7. package/dist/language-model.js +170 -34
  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/switch-mode.d.ts +5 -4
  26. package/dist/protocol/switch-mode.js +12 -4
  27. package/dist/protocol/tool-call-bridge.js +1 -1
  28. package/dist/protocol/tools.d.ts +30 -4
  29. package/dist/protocol/tools.js +185 -58
  30. package/dist/session.d.ts +7 -1
  31. package/dist/shared.d.ts +3 -1
  32. package/dist/shared.js +3 -1
  33. package/dist/shell-timeout.d.ts +13 -0
  34. package/dist/shell-timeout.js +38 -0
  35. package/dist/todo-store.d.ts +29 -0
  36. package/dist/todo-store.js +78 -0
  37. package/dist/web-tools.d.ts +13 -0
  38. package/dist/web-tools.js +83 -5
  39. package/package.json +6 -3
@@ -13,6 +13,67 @@ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-
13
13
  // probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
14
14
  // field number for every exec variant, so this is also the result field.
15
15
  export const REQUEST_CONTEXT_RESULT_FIELD = 10;
16
+ export const OPENCODE_1_TOOL_DIALECT = {
17
+ filePathKey: "filePath",
18
+ shellTool: "bash",
19
+ };
20
+ function jsonSchemaProperties(schema) {
21
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
22
+ return undefined;
23
+ const obj = schema;
24
+ if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
25
+ return obj.properties;
26
+ }
27
+ const nested = obj.jsonSchema;
28
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
29
+ const props = nested.properties;
30
+ if (props && typeof props === "object" && !Array.isArray(props)) {
31
+ return props;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ /** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
37
+ export function opencodePathArg(args) {
38
+ if (!args)
39
+ return undefined;
40
+ return str(args.filePath) ?? str(args.path) ?? str(args.file_path);
41
+ }
42
+ /**
43
+ * Infer the host tool dialect from advertised AI SDK schemas.
44
+ * OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
45
+ */
46
+ export function hostToolDialectFromTools(tools) {
47
+ let filePathKey;
48
+ for (const name of ["read", "write", "edit"]) {
49
+ const tool = tools.find((candidate) => candidate.name === name);
50
+ const props = jsonSchemaProperties(tool?.inputSchema);
51
+ if (!props)
52
+ continue;
53
+ if ("path" in props && !("filePath" in props)) {
54
+ filePathKey = "path";
55
+ break;
56
+ }
57
+ if ("filePath" in props) {
58
+ filePathKey = "filePath";
59
+ break;
60
+ }
61
+ }
62
+ const names = new Set(tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
63
+ const shellTool = names.has("shell") && !names.has("bash") ? "shell" : "bash";
64
+ if (!filePathKey) {
65
+ // OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
66
+ filePathKey = shellTool === "shell" ? "path" : "filePath";
67
+ }
68
+ return { filePathKey, shellTool };
69
+ }
70
+ function assignHostFilePath(args, filePath, dialect) {
71
+ delete args.filePath;
72
+ delete args.path;
73
+ delete args.file_path;
74
+ if (filePath)
75
+ args[dialect.filePathKey] = filePath;
76
+ }
16
77
  export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
17
78
  export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
18
79
  export const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
@@ -171,15 +232,17 @@ export function resolveCustomWebToolAlias(toolName, aliases) {
171
232
  export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
172
233
  if (tools.length === 0)
173
234
  return [];
174
- const order = [];
175
235
  const byServer = new Map();
176
- for (const t of tools) {
236
+ // OpenCode's tool catalog is a set even though the hook exposes it as an
237
+ // array. Keep both the server and per-server tool order canonical so a host
238
+ // that enumerates the same catalog differently does not invalidate Cursor's
239
+ // RequestContext prefix cache.
240
+ for (const t of [...tools].sort((left, right) => left.name.localeCompare(right.name))) {
177
241
  const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
178
242
  let list = byServer.get(id.server);
179
243
  if (!list) {
180
244
  list = [];
181
245
  byServer.set(id.server, list);
182
- order.push(id.server);
183
246
  }
184
247
  list.push({
185
248
  tool_name: t.sourceName ? t.name : id.toolName,
@@ -187,7 +250,14 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
187
250
  input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
188
251
  });
189
252
  }
190
- return order.map((server) => ({
253
+ const orderedServers = [...byServer.keys()].sort((left, right) => {
254
+ if (left === providerIdentifier)
255
+ return right === providerIdentifier ? 0 : -1;
256
+ if (right === providerIdentifier)
257
+ return 1;
258
+ return left.localeCompare(right);
259
+ });
260
+ return orderedServers.map((server) => ({
191
261
  server_name: server,
192
262
  server_identifier: server,
193
263
  tools: byServer.get(server),
@@ -262,6 +332,7 @@ const opencodeToolToCursor = {
262
332
  write: "write_args",
263
333
  grep: "grep_args",
264
334
  bash: "shell_stream_args",
335
+ shell: "shell_stream_args",
265
336
  task: "subagent_args",
266
337
  mcp: "mcp_args",
267
338
  };
@@ -348,33 +419,54 @@ const CURSOR_SUBAGENT_TYPE_TO_OPENCODE = {
348
419
  security_review: "explore",
349
420
  };
350
421
  const SUBAGENT_CATALOG_MARKER = "Available agent types and the tools they have access to:";
422
+ const SUBAGENT_INLINE_MARKER = "Available subagents:";
351
423
  function parseSubagentDescriptionCatalog(description) {
352
424
  if (!description)
353
425
  return { found: false, agents: [] };
354
426
  const marker = description.indexOf(SUBAGENT_CATALOG_MARKER);
355
- if (marker < 0)
427
+ if (marker >= 0) {
428
+ const agents = [];
429
+ const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
430
+ let started = false;
431
+ for (const line of lines) {
432
+ const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
433
+ if (!match) {
434
+ if (started && line.trim())
435
+ break;
436
+ continue;
437
+ }
438
+ started = true;
439
+ const name = match[1].trim().replace(/^`|`$/g, "");
440
+ if (!name)
441
+ continue;
442
+ const agentDescription = match[2].trim();
443
+ agents.push({
444
+ name,
445
+ ...(agentDescription ? { description: agentDescription } : {}),
446
+ });
447
+ }
448
+ return { found: true, agents };
449
+ }
450
+ // OpenCode 2 `subagent` inlines the list after "Available subagents:".
451
+ const inlineAt = description.indexOf(SUBAGENT_INLINE_MARKER);
452
+ if (inlineAt < 0)
356
453
  return { found: false, agents: [] };
454
+ const rest = description.slice(inlineAt + SUBAGENT_INLINE_MARKER.length);
357
455
  const agents = [];
358
- const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
359
- let started = false;
360
- for (const line of lines) {
361
- const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
362
- if (!match) {
363
- if (started && line.trim())
364
- break;
365
- continue;
366
- }
367
- started = true;
368
- const name = match[1].trim().replace(/^`|`$/g, "");
369
- if (!name)
370
- continue;
371
- const agentDescription = match[2].trim();
456
+ const matches = rest.matchAll(/-\s+([A-Za-z0-9_-]+):\s*/g);
457
+ const hits = [...matches];
458
+ for (let i = 0; i < hits.length; i++) {
459
+ const hit = hits[i];
460
+ const name = hit[1];
461
+ const start = (hit.index ?? 0) + hit[0].length;
462
+ const end = i + 1 < hits.length ? (hits[i + 1].index ?? rest.length) : rest.length;
463
+ const agentDescription = rest.slice(start, end).trim();
372
464
  agents.push({
373
465
  name,
374
466
  ...(agentDescription ? { description: agentDescription } : {}),
375
467
  });
376
468
  }
377
- return { found: true, agents };
469
+ return { found: agents.length > 0, agents };
378
470
  }
379
471
  function subagentTypeEnumValues(schema) {
380
472
  const out = new Set();
@@ -391,7 +483,8 @@ function subagentTypeEnumValues(schema) {
391
483
  return;
392
484
  }
393
485
  const record = value;
394
- if (propertyName === "subagent_type" && Array.isArray(record.enum)) {
486
+ if ((propertyName === "subagent_type" || propertyName === "agent")
487
+ && Array.isArray(record.enum)) {
395
488
  for (const item of record.enum) {
396
489
  if (typeof item === "string" && item)
397
490
  out.add(item);
@@ -405,16 +498,17 @@ function subagentTypeEnumValues(schema) {
405
498
  }
406
499
  /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
407
500
  export function extractHostSubagentCatalog(tools) {
408
- const executorTool = tools.find((tool) => tool.name === "task");
501
+ const executorTool = tools.find((tool) => tool.name === "task")
502
+ ?? tools.find((tool) => tool.name === "subagent");
409
503
  if (!executorTool)
410
504
  return { agents: [], complete: true };
411
505
  const described = parseSubagentDescriptionCatalog(executorTool.description);
412
506
  const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
413
507
  const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
414
- // A structured subagent_type enum is stricter than prose, so it is authoritative.
508
+ // A structured subagent_type/agent enum is stricter than prose, so it is authoritative.
415
509
  const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
416
510
  return {
417
- executor: "task",
511
+ executor: executorTool.name === "subagent" ? "subagent" : "task",
418
512
  agents: [...names].map((name) => ({
419
513
  name,
420
514
  ...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
@@ -474,13 +568,19 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
474
568
  if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
475
569
  return;
476
570
  const advertised = new Set(advertisedToolNames);
477
- if (!advertised.has("task"))
571
+ const executor = advertised.has("task")
572
+ ? "task"
573
+ : advertised.has("subagent")
574
+ ? "subagent"
575
+ : undefined;
576
+ if (!executor)
478
577
  return;
479
- const executor = "task";
480
578
  const description = str(parsed.args.description) ?? "";
481
579
  const prompt = str(parsed.args.prompt) ?? "";
482
580
  const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
483
- str(parsed.args.subagent_type) ?? "";
581
+ str(parsed.args.subagent_type) ??
582
+ str(parsed.args.agent) ??
583
+ "";
484
584
  const subagentType = resolveCursorSubagentType(cursorSubagentType, catalog);
485
585
  if (!subagentType) {
486
586
  const available = catalog?.agents.map((agent) => agent.name).join(", ") || "none";
@@ -489,15 +589,23 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
489
589
  `Available subagents: ${available}.`;
490
590
  return;
491
591
  }
492
- const resumeAgentId = str(parsed.args.task_id);
592
+ const resumeAgentId = str(parsed.args.task_id) ?? str(parsed.args.sessionID);
493
593
  parsed.toolName = executor;
494
- parsed.args = {
495
- description,
496
- prompt,
497
- subagent_type: subagentType,
498
- ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
499
- ...(parsed.args.background === true ? { background: true } : {}),
500
- };
594
+ parsed.args = executor === "subagent"
595
+ ? {
596
+ agent: subagentType,
597
+ description,
598
+ prompt,
599
+ ...(resumeAgentId ? { sessionID: resumeAgentId } : {}),
600
+ ...(parsed.args.background === true ? { background: true } : {}),
601
+ }
602
+ : {
603
+ description,
604
+ prompt,
605
+ subagent_type: subagentType,
606
+ ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
607
+ ...(parsed.args.background === true ? { background: true } : {}),
608
+ };
501
609
  }
502
610
  /** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
503
611
  const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
@@ -625,7 +733,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
625
733
  const advertised = new Set(advertisedToolNames);
626
734
  if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
627
735
  return false;
628
- const filePath = str(parsed.args.filePath);
736
+ const filePath = opencodePathArg(parsed.args);
629
737
  const content = stringValue(parsed.args.content);
630
738
  if (!filePath || content === undefined || !editPath)
631
739
  return false;
@@ -651,8 +759,11 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
651
759
  if (!replacement)
652
760
  return false;
653
761
  parsed.toolName = "edit";
762
+ const filePathKey = typeof parsed.args.path === "string" && typeof parsed.args.filePath !== "string"
763
+ ? "path"
764
+ : "filePath";
654
765
  parsed.args = {
655
- filePath,
766
+ [filePathKey]: filePath,
656
767
  oldString: replacement.oldString,
657
768
  newString: replacement.newString,
658
769
  };
@@ -687,7 +798,7 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
687
798
  if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
688
799
  return;
689
800
  const requested = parsed.toolName;
690
- const filePath = str(parsed.args.filePath);
801
+ const filePath = opencodePathArg(parsed.args);
691
802
  const refuse = (reason) => {
692
803
  parsed.toolName = APPLY_PATCH_TOOL;
693
804
  parsed.args = {};
@@ -775,9 +886,7 @@ export function rejectPartialReadMutation(parsed) {
775
886
  || !content.includes("[Partial read:")
776
887
  || !content.includes("It is NOT the complete file."))
777
888
  return;
778
- const filePath = typeof parsed.args.filePath === "string"
779
- ? parsed.args.filePath
780
- : "the target file";
889
+ const filePath = opencodePathArg(parsed.args) ?? "the target file";
781
890
  const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
782
891
  parsed.localError =
783
892
  `NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
@@ -786,7 +895,7 @@ export function rejectPartialReadMutation(parsed) {
786
895
  ? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
787
896
  : "Read the remaining file ranges, then use a targeted edit or Update File patch.");
788
897
  }
789
- export function parseExecServerMessage(msg) {
898
+ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
790
899
  const id = msg.id;
791
900
  if (id === undefined)
792
901
  return undefined;
@@ -823,7 +932,7 @@ export function parseExecServerMessage(msg) {
823
932
  return {
824
933
  id,
825
934
  execId,
826
- toolName: "bash",
935
+ toolName: dialect.shellTool,
827
936
  args,
828
937
  resultField,
829
938
  resultMetadata: {
@@ -877,7 +986,7 @@ export function parseExecServerMessage(msg) {
877
986
  // tool name (Cursor's model may have shortened "opencode-read" → "read") and
878
987
  // decode the argument map back into JSON for opencode to execute.
879
988
  const m = msg.mcp_args ?? {};
880
- const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), decodeMcpArgs(m.args), "mcp_args");
989
+ const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), decodeMcpArgs(m.args), "mcp_args", dialect);
881
990
  return {
882
991
  id,
883
992
  execId,
@@ -896,8 +1005,7 @@ export function parseExecServerMessage(msg) {
896
1005
  const oldString = replacement ? stringValue(replacement.old_text) : undefined;
897
1006
  const newString = replacement ? stringValue(replacement.new_text) : undefined;
898
1007
  const args = {};
899
- if (path)
900
- args.filePath = path;
1008
+ assignHostFilePath(args, path, dialect);
901
1009
  if (oldString !== undefined)
902
1010
  args.oldString = oldString;
903
1011
  if (newString !== undefined)
@@ -916,7 +1024,7 @@ export function parseExecServerMessage(msg) {
916
1024
  const toolName = cursorToolToOpencode[execVariant];
917
1025
  if (!toolName)
918
1026
  return undefined;
919
- const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
1027
+ const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant, dialect);
920
1028
  const rawArgs = msg[execVariant] ?? {};
921
1029
  const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
922
1030
  ? shellStreamResultMetadata(rawArgs)
@@ -980,7 +1088,7 @@ function shellStreamResultMetadata(raw) {
980
1088
  * Without this, OpenCode rejects calls with InvalidArgumentsError
981
1089
  * (e.g. `path` instead of `filePath`, `file_text` instead of `content`).
982
1090
  */
983
- export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1091
+ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OPENCODE_1_TOOL_DIALECT) {
984
1092
  const cleaned = {};
985
1093
  for (const [k, v] of Object.entries(raw)) {
986
1094
  if (v === undefined || v === null)
@@ -996,7 +1104,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
996
1104
  // Native ls_args → OpenCode read (directory listing via read).
997
1105
  if (execVariant === "ls_args") {
998
1106
  const filePath = str(cleaned.path) ?? str(cleaned.filePath);
999
- return { toolName: "read", args: filePath ? { filePath } : {} };
1107
+ return { toolName: "read", args: filePath ? { [dialect.filePathKey]: filePath } : {} };
1000
1108
  }
1001
1109
  // F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
1002
1110
  // we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
@@ -1004,7 +1112,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1004
1112
  if (execVariant === "delete_args") {
1005
1113
  const target = str(cleaned.path) ?? str(cleaned.filePath);
1006
1114
  return {
1007
- toolName: "bash",
1115
+ toolName: dialect.shellTool,
1008
1116
  args: target ? { command: `rm -f -- ${shellQuote(target)}` } : { command: "true" },
1009
1117
  };
1010
1118
  }
@@ -1012,8 +1120,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1012
1120
  case "read": {
1013
1121
  const args = {};
1014
1122
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1015
- if (filePath)
1016
- args.filePath = filePath;
1123
+ assignHostFilePath(args, filePath, dialect);
1017
1124
  // Cursor often sends offset=0/limit=0 as protobuf defaults. OpenCode's
1018
1125
  // read treats limit=0 as "read zero lines" (empty content) — omit zeros.
1019
1126
  const offset = num(cleaned.offset);
@@ -1027,8 +1134,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1027
1134
  case "write": {
1028
1135
  const args = {};
1029
1136
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1030
- if (filePath)
1031
- args.filePath = filePath;
1137
+ assignHostFilePath(args, filePath, dialect);
1032
1138
  // Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
1033
1139
  // non-empty and only falls back to `file_text`; mirror that order so a
1034
1140
  // byte-encoded write is not silently seen as empty content.
@@ -1060,8 +1166,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1060
1166
  }
1061
1167
  const args = {};
1062
1168
  const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
1063
- if (filePath)
1064
- args.filePath = filePath;
1169
+ assignHostFilePath(args, filePath, dialect);
1065
1170
  const oldString = stringValue(cleaned.oldString) ?? stringValue(cleaned.old_string);
1066
1171
  if (oldString !== undefined)
1067
1172
  args.oldString = oldString;
@@ -1072,7 +1177,8 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1072
1177
  args.replaceAll = cleaned.replaceAll;
1073
1178
  return { toolName: "edit", args };
1074
1179
  }
1075
- case "bash": {
1180
+ case "bash":
1181
+ case "shell": {
1076
1182
  const args = {};
1077
1183
  const command = str(cleaned.command);
1078
1184
  if (command)
@@ -1083,7 +1189,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
1083
1189
  const timeout = num(cleaned.timeout);
1084
1190
  if (timeout !== undefined)
1085
1191
  args.timeout = timeout;
1086
- return { toolName: "bash", args };
1192
+ return { toolName: dialect.shellTool, args };
1087
1193
  }
1088
1194
  case "grep": {
1089
1195
  const pattern = str(cleaned.pattern);
@@ -1193,12 +1299,33 @@ function num(v) {
1193
1299
  return Number(v);
1194
1300
  return undefined;
1195
1301
  }
1302
+ /**
1303
+ * Fallback OpenCode task title when Cursor's SubagentArgs has no description.
1304
+ * Prefer {@link preferCorrelatedTaskDescription} when a TaskToolCall is available —
1305
+ * that carries Cursor's real 3–5 word title. This only slices the prompt.
1306
+ */
1196
1307
  function describeSubagentTask(prompt, subagentType) {
1197
1308
  const words = prompt?.replace(/\s+/g, " ").trim().split(" ").filter(Boolean).slice(0, 5);
1198
1309
  if (words?.length)
1199
1310
  return words.join(" ");
1200
1311
  return `${subagentType || "Delegated"} task`;
1201
1312
  }
1313
+ /**
1314
+ * Replace the prompt-derived task title with Cursor's TaskToolCall description
1315
+ * when the display call is correlated via tool_call_id. SubagentArgs itself has
1316
+ * no description field, so without this OpenCode shows a cut-off first-five-words
1317
+ * slice of the prompt.
1318
+ */
1319
+ export function preferCorrelatedTaskDescription(parsed, description) {
1320
+ if (parsed.toolName !== "task" && parsed.toolName !== "subagent")
1321
+ return;
1322
+ if (parsed.resultField !== "subagent_result")
1323
+ return;
1324
+ const trimmed = description?.trim();
1325
+ if (!trimmed)
1326
+ return;
1327
+ parsed.args.description = trimmed;
1328
+ }
1202
1329
  function shellQuote(s) {
1203
1330
  return `'${s.replace(/'/g, `'\\''`)}'`;
1204
1331
  }
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 { HostSubagentCatalog, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
4
+ import type { HostSubagentCatalog, HostToolDialect, OpencodeToolDef, ToolAliasRegistry } from "./protocol/tools.js";
5
5
  import type { CursorConversationTokenDetails } from "./protocol/token-details.js";
6
6
  export type Frame = {
7
7
  flags: number;
@@ -134,6 +134,10 @@ export type CursorSession = {
134
134
  };
135
135
  /** OpenCode session whose own or descendant activity renews tool leases. */
136
136
  openCodeSessionId?: string;
137
+ /** Host primary agent whose prompt/permissions this Run was seeded with. */
138
+ hostAgent?: string;
139
+ /** Stable host-system + provider-guidance identity for restart validation. */
140
+ stableSystemPromptHash?: string;
137
141
  /** Completed compaction must rebase once before resuming a normal agent. */
138
142
  postCompactionRebase?: boolean;
139
143
  /** Last real host catalog, retained only as a lifecycle-turn fallback. */
@@ -170,6 +174,8 @@ export type CursorSession = {
170
174
  toolDescriptors: Array<Record<string, unknown>>;
171
175
  /** Cursor-facing web alias → exact executable host tool for this Run. */
172
176
  toolAliases?: ToolAliasRegistry;
177
+ /** OpenCode 1.x `filePath`/`bash` vs 2.x `path`/`shell`, from advertised schemas. */
178
+ hostToolDialect?: HostToolDialect;
173
179
  /** Spawnable host agents extracted from this turn's Task/Actor definition. */
174
180
  subagentCatalog?: HostSubagentCatalog;
175
181
  /** Full RequestContext for exec #10 replies. */
package/dist/shared.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
4
  export declare const CURSOR_PROVIDER_ID = "cursor";
5
5
  /** Private provider option injected by the OpenCode plugin for summary turns. */
6
6
  export declare const CURSOR_COMPACTION_OPTION = "opencodeCompaction";
7
+ /** Current OpenCode primary agent; used to invalidate incompatible checkpoints. */
8
+ export declare const CURSOR_HOST_AGENT_OPTION = "opencodeHostAgent";
7
9
  export declare const TOKEN_EXPIRY_THRESHOLD_S = 300;
8
10
  export declare const RUN_PATH = "/agent.v1.AgentService/Run";
9
11
  export declare const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
@@ -13,7 +15,7 @@ export declare const MODEL_CACHE_FILE = "cursor-models.json";
13
15
  export declare const MODEL_CACHE_SCHEMA_VERSION = 3;
14
16
  export declare const MODEL_CACHE_TTL_MS = 86400000;
15
17
  export declare const CONVERSATION_CACHE_DIR = "cursor-conversations";
16
- export declare const CONVERSATION_CACHE_SCHEMA_VERSION = 3;
18
+ export declare const CONVERSATION_CACHE_SCHEMA_VERSION = 4;
17
19
  export declare const CONVERSATION_CACHE_TTL_MS = 86400000;
18
20
  export declare const VERSION_CACHE_FILE = "cursor-client-version.json";
19
21
  export declare const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
package/dist/shared.js CHANGED
@@ -4,6 +4,8 @@ export const FALLBACK_CLIENT_VERSION = "cli-2026.07.09-a3815c0";
4
4
  export const CURSOR_PROVIDER_ID = "cursor";
5
5
  /** Private provider option injected by the OpenCode plugin for summary turns. */
6
6
  export const CURSOR_COMPACTION_OPTION = "opencodeCompaction";
7
+ /** Current OpenCode primary agent; used to invalidate incompatible checkpoints. */
8
+ export const CURSOR_HOST_AGENT_OPTION = "opencodeHostAgent";
7
9
  export const TOKEN_EXPIRY_THRESHOLD_S = 300;
8
10
  export const RUN_PATH = "/agent.v1.AgentService/Run";
9
11
  export const AVAILABLE_MODELS_PATH = "/aiserver.v1.AiService/AvailableModels";
@@ -13,7 +15,7 @@ export const MODEL_CACHE_FILE = "cursor-models.json";
13
15
  export const MODEL_CACHE_SCHEMA_VERSION = 3;
14
16
  export const MODEL_CACHE_TTL_MS = 86_400_000;
15
17
  export const CONVERSATION_CACHE_DIR = "cursor-conversations";
16
- export const CONVERSATION_CACHE_SCHEMA_VERSION = 3;
18
+ export const CONVERSATION_CACHE_SCHEMA_VERSION = 4;
17
19
  export const CONVERSATION_CACHE_TTL_MS = 86_400_000;
18
20
  export const VERSION_CACHE_FILE = "cursor-client-version.json";
19
21
  export const CONTENT_TYPE_CONNECT_PROTO = "application/connect+proto";
@@ -85,9 +85,19 @@ export declare function releaseCursorShellEnv(toolCallId: string): void;
85
85
  * same materialized wrapper backs the direct-command sh/dash fallback.
86
86
  */
87
87
  export declare function cursorShellEnvForCall(toolCallId: string | undefined): Record<string, string> | undefined;
88
+ /**
89
+ * OpenCode 2.0 `shell.create.before` has no tool-call id. Correlate the pending
90
+ * wrap by command + working directory when possible, then fall back to the
91
+ * original command for hosts that omit the directory.
92
+ */
93
+ export declare function cursorShellEnvForCommand(command: string | undefined, workingDirectory?: string): Record<string, string> | undefined;
88
94
  /**
89
95
  * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
90
96
  * Does not record outcomes — use {@link captureCursorShellResult} for that.
97
+ *
98
+ * OpenCode 2.0 `Tool.Result.output` is structured (an object for shell), not
99
+ * a string. Non-string input is returned unchanged so the 2.0 after-hook can
100
+ * pass structured output through safely.
91
101
  */
92
102
  export declare function sanitizeCursorShellDisplayOutput(output: string, policy?: CursorShellPolicy): string;
93
103
  /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
@@ -95,6 +105,9 @@ export declare function sanitizeRegisteredCursorShellOutput(toolCallId: string,
95
105
  /**
96
106
  * Capture Bash completion in the classic plugin's after hook. Returns the
97
107
  * sanitized output that OpenCode should store and render.
108
+ *
109
+ * Guards non-string output (OpenCode 2.0 structured `Tool.Result.output`)
110
+ * by returning it unchanged.
98
111
  */
99
112
  export declare function captureCursorShellResult(toolCallId: string, output: string, metadata?: Record<string, unknown>): string;
100
113
  /** Consume the structured result, with an inline fallback when no plugin hook ran. */
@@ -269,6 +269,31 @@ export function cursorShellEnvForCall(toolCallId) {
269
269
  pendingEnvWraps.delete(toolCallId);
270
270
  return wrap.env;
271
271
  }
272
+ /**
273
+ * OpenCode 2.0 `shell.create.before` has no tool-call id. Correlate the pending
274
+ * wrap by command + working directory when possible, then fall back to the
275
+ * original command for hosts that omit the directory.
276
+ */
277
+ export function cursorShellEnvForCommand(command, workingDirectory) {
278
+ if (typeof command !== "string" || !command)
279
+ return undefined;
280
+ if (workingDirectory) {
281
+ for (const [id, policy] of policies) {
282
+ if (!pendingEnvWraps.has(id))
283
+ continue;
284
+ if (policy.command === command && policy.workingDirectory === workingDirectory) {
285
+ return cursorShellEnvForCall(id);
286
+ }
287
+ }
288
+ }
289
+ for (const [id, policy] of policies) {
290
+ if (!pendingEnvWraps.has(id))
291
+ continue;
292
+ if (policy.command === command)
293
+ return cursorShellEnvForCall(id);
294
+ }
295
+ return undefined;
296
+ }
272
297
  function withoutMarker(output, index) {
273
298
  let clean = output.slice(0, index).replace(/[\t ]+$/gm, "").replace(/\n{2,}$/, "\n");
274
299
  if (clean.trim() === "" || clean.trim() === "(no output)")
@@ -390,8 +415,14 @@ function parseBackgroundSpawnOutcome(output, policy) {
390
415
  /**
391
416
  * Strip private wrapper sentinels / OpenCode timeout envelopes for display.
392
417
  * Does not record outcomes — use {@link captureCursorShellResult} for that.
418
+ *
419
+ * OpenCode 2.0 `Tool.Result.output` is structured (an object for shell), not
420
+ * a string. Non-string input is returned unchanged so the 2.0 after-hook can
421
+ * pass structured output through safely.
393
422
  */
394
423
  export function sanitizeCursorShellDisplayOutput(output, policy) {
424
+ if (typeof output !== "string")
425
+ return output;
395
426
  if (policy?.backgroundSpawn) {
396
427
  const spawn = parseBackgroundSpawnOutcome(output, policy);
397
428
  if (spawn)
@@ -413,6 +444,8 @@ export function sanitizeCursorShellDisplayOutput(output, policy) {
413
444
  }
414
445
  /** Sanitize a secondary display string (e.g. Bash `metadata.output`) for a registered call. */
415
446
  export function sanitizeRegisteredCursorShellOutput(toolCallId, output) {
447
+ if (typeof output !== "string")
448
+ return output;
416
449
  if (typeof toolCallId !== "string" || !toolCallId)
417
450
  return output;
418
451
  return sanitizeCursorShellDisplayOutput(output, policies.get(toolCallId));
@@ -420,8 +453,13 @@ export function sanitizeRegisteredCursorShellOutput(toolCallId, output) {
420
453
  /**
421
454
  * Capture Bash completion in the classic plugin's after hook. Returns the
422
455
  * sanitized output that OpenCode should store and render.
456
+ *
457
+ * Guards non-string output (OpenCode 2.0 structured `Tool.Result.output`)
458
+ * by returning it unchanged.
423
459
  */
424
460
  export function captureCursorShellResult(toolCallId, output, metadata) {
461
+ if (typeof output !== "string")
462
+ return output;
425
463
  if (typeof toolCallId !== "string" || !toolCallId.startsWith("cursor_"))
426
464
  return output;
427
465
  const policy = policies.get(toolCallId);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Per-session todo list used when the host does not ship `todowrite` /
3
+ * `todoread` (OpenCode 2 dropped those builtins; OpenCode 1.x still has
4
+ * `todowrite`).
5
+ *
6
+ * In-memory only — same lifetime as the provider process. A restart clears
7
+ * the list until the next write/read, matching the mirrored Cursor-todo
8
+ * snapshot in `language-model.ts`.
9
+ */
10
+ export declare const TODOWRITE_TOOL = "todowrite";
11
+ export declare const TODOREAD_TOOL = "todoread";
12
+ export declare const TODO_STATUSES: readonly ["pending", "in_progress", "completed", "cancelled"];
13
+ export type TodoStatus = (typeof TODO_STATUSES)[number];
14
+ export declare const TODO_PRIORITIES: readonly ["high", "medium", "low"];
15
+ export type TodoPriority = (typeof TODO_PRIORITIES)[number];
16
+ export type SessionTodo = {
17
+ id: string;
18
+ content: string;
19
+ status: TodoStatus;
20
+ priority: TodoPriority;
21
+ };
22
+ /** Normalize one host/Cursor todo item. Drops entries with empty content. */
23
+ export declare function normalizeSessionTodo(value: unknown, index: number): SessionTodo | undefined;
24
+ export declare function normalizeSessionTodos(value: unknown): SessionTodo[];
25
+ export declare function getSessionTodos(sessionID: string): SessionTodo[];
26
+ export declare function setSessionTodos(sessionID: string, todos: unknown): SessionTodo[];
27
+ export declare function clearSessionTodos(sessionID: string): void;
28
+ /** Test-only: drop every session list. */
29
+ export declare function clearAllSessionTodos(): void;