cursor-opencode-provider 0.7.6 → 0.8.1

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 (101) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +36 -15
  3. package/SECURITY.md +43 -7
  4. package/dist/agent-url.js +2 -2
  5. package/dist/auth-renewal.d.ts +85 -0
  6. package/dist/auth-renewal.js +284 -0
  7. package/dist/auth.d.ts +38 -22
  8. package/dist/auth.js +79 -96
  9. package/dist/classic-tools.d.ts +15 -0
  10. package/dist/classic-tools.js +45 -0
  11. package/dist/context/build.d.ts +62 -7
  12. package/dist/context/build.js +127 -95
  13. package/dist/context/dynamic-catalog.d.ts +2 -58
  14. package/dist/context/dynamic-catalog.js +16 -161
  15. package/dist/context/epoch.d.ts +6 -4
  16. package/dist/context/epoch.js +22 -10
  17. package/dist/context/frozen.js +42 -15
  18. package/dist/context/index.d.ts +3 -1
  19. package/dist/context/index.js +3 -1
  20. package/dist/context/overlay.d.ts +2 -23
  21. package/dist/context/overlay.js +6 -30
  22. package/dist/context/paths.d.ts +51 -21
  23. package/dist/context/paths.js +78 -27
  24. package/dist/context/rules.d.ts +3 -15
  25. package/dist/context/rules.js +10 -186
  26. package/dist/context/skills-bridge.d.ts +22 -0
  27. package/dist/context/skills-bridge.js +20 -0
  28. package/dist/context/skills.d.ts +31 -8
  29. package/dist/context/skills.js +187 -86
  30. package/dist/debug.d.ts +2 -0
  31. package/dist/debug.js +4 -0
  32. package/dist/errors.d.ts +13 -0
  33. package/dist/errors.js +27 -3
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +119 -15
  36. package/dist/host-plan-file.d.ts +28 -0
  37. package/dist/host-plan-file.js +76 -0
  38. package/dist/image-input.d.ts +18 -0
  39. package/dist/image-input.js +119 -28
  40. package/dist/image-save.d.ts +10 -2
  41. package/dist/image-save.js +35 -23
  42. package/dist/index.d.ts +10 -0
  43. package/dist/language-model.d.ts +86 -25
  44. package/dist/language-model.js +1295 -369
  45. package/dist/model-config.d.ts +9 -0
  46. package/dist/model-config.js +28 -1
  47. package/dist/opencode2/catalog.d.ts +1 -0
  48. package/dist/opencode2/catalog.js +2 -0
  49. package/dist/opencode2/image-save-tool.d.ts +8 -0
  50. package/dist/opencode2/image-save-tool.js +84 -0
  51. package/dist/opencode2/integration.d.ts +30 -7
  52. package/dist/opencode2/integration.js +75 -46
  53. package/dist/opencode2/types.d.ts +15 -0
  54. package/dist/parallel-step.d.ts +65 -0
  55. package/dist/parallel-step.js +102 -0
  56. package/dist/plugin-opencode2.js +58 -56
  57. package/dist/plugin.d.ts +0 -10
  58. package/dist/plugin.js +207 -127
  59. package/dist/pricing-data.d.ts +14 -1
  60. package/dist/pricing-data.js +14 -1
  61. package/dist/protocol/ask-question.d.ts +12 -1
  62. package/dist/protocol/ask-question.js +77 -3
  63. package/dist/protocol/blob-store.js +3 -2
  64. package/dist/protocol/conversation-bind.js +1 -1
  65. package/dist/protocol/conversation-persistence.d.ts +2 -0
  66. package/dist/protocol/conversation-persistence.js +11 -1
  67. package/dist/protocol/conversation-state.d.ts +2 -0
  68. package/dist/protocol/conversation-state.js +19 -0
  69. package/dist/protocol/create-plan.d.ts +47 -25
  70. package/dist/protocol/create-plan.js +35 -24
  71. package/dist/protocol/interactions.d.ts +26 -17
  72. package/dist/protocol/interactions.js +80 -31
  73. package/dist/protocol/messages.js +96 -18
  74. package/dist/protocol/request.d.ts +16 -9
  75. package/dist/protocol/request.js +17 -25
  76. package/dist/protocol/switch-mode.d.ts +66 -27
  77. package/dist/protocol/switch-mode.js +147 -73
  78. package/dist/protocol/token-details.d.ts +2 -0
  79. package/dist/protocol/token-details.js +11 -1
  80. package/dist/protocol/tool-call-bridge.d.ts +4 -0
  81. package/dist/protocol/tool-call-bridge.js +14 -0
  82. package/dist/protocol/tools.d.ts +33 -11
  83. package/dist/protocol/tools.js +165 -103
  84. package/dist/protocol/turn-provenance.d.ts +52 -0
  85. package/dist/protocol/turn-provenance.js +174 -0
  86. package/dist/replay-safety.d.ts +6 -0
  87. package/dist/replay-safety.js +56 -6
  88. package/dist/session.d.ts +44 -2
  89. package/dist/session.js +45 -1
  90. package/dist/shared.d.ts +7 -0
  91. package/dist/shared.js +7 -0
  92. package/dist/todo-store.d.ts +2 -0
  93. package/dist/todo-store.js +19 -0
  94. package/dist/transport/connect.d.ts +2 -0
  95. package/dist/transport/connect.js +23 -6
  96. package/dist/usage.js +23 -6
  97. package/package.json +3 -2
  98. package/dist/context/agents.d.ts +0 -12
  99. package/dist/context/agents.js +0 -136
  100. package/dist/plan-execution-kickoff.d.ts +0 -57
  101. package/dist/plan-execution-kickoff.js +0 -186
@@ -25,6 +25,12 @@ export declare const CURSOR_MODEL_COSTS: {
25
25
  readonly cache_read: 0.1;
26
26
  readonly cache_write: 1.25;
27
27
  };
28
+ readonly "claude-haiku-5-5": {
29
+ readonly input: 0.1;
30
+ readonly output: 0.5;
31
+ readonly cache_read: 0.01;
32
+ readonly cache_write: 0.125;
33
+ };
28
34
  readonly "claude-opus-4-5": {
29
35
  readonly input: 5;
30
36
  readonly output: 25;
@@ -94,7 +100,7 @@ export declare const CURSOR_MODEL_COSTS: {
94
100
  readonly "claude-sonnet-5-5": {
95
101
  readonly input: 2;
96
102
  readonly output: 10;
97
- readonly cache_read: 0.2;
103
+ readonly cache_read: 0.1;
98
104
  readonly cache_write: 2.5;
99
105
  };
100
106
  readonly "composer-2.5": {
@@ -311,6 +317,10 @@ export declare const CURSOR_MODEL_CONTEXTS: {
311
317
  readonly "claude-haiku-4-5": {
312
318
  readonly maxContext: 200000;
313
319
  };
320
+ readonly "claude-haiku-5-5": {
321
+ readonly maxContext: 300000;
322
+ readonly maxContextForMaxMode: 1000000;
323
+ };
314
324
  readonly "claude-opus-4-5": {
315
325
  readonly maxContext: 200000;
316
326
  readonly maxContextForMaxMode: 200000;
@@ -465,6 +475,9 @@ export declare const CURSOR_MODEL_CAPABILITIES: {
465
475
  readonly "claude-haiku-4-5": {
466
476
  readonly supportsImages: true;
467
477
  };
478
+ readonly "claude-haiku-5-5": {
479
+ readonly supportsImages: true;
480
+ };
468
481
  readonly "claude-opus-4-5": {
469
482
  readonly supportsImages: true;
470
483
  };
@@ -25,6 +25,12 @@ export const CURSOR_MODEL_COSTS = {
25
25
  "cache_read": 0.1,
26
26
  "cache_write": 1.25
27
27
  },
28
+ "claude-haiku-5-5": {
29
+ "input": 0.1,
30
+ "output": 0.5,
31
+ "cache_read": 0.01,
32
+ "cache_write": 0.125
33
+ },
28
34
  "claude-opus-4-5": {
29
35
  "input": 5,
30
36
  "output": 25,
@@ -94,7 +100,7 @@ export const CURSOR_MODEL_COSTS = {
94
100
  "claude-sonnet-5-5": {
95
101
  "input": 2,
96
102
  "output": 10,
97
- "cache_read": 0.2,
103
+ "cache_read": 0.1,
98
104
  "cache_write": 2.5
99
105
  },
100
106
  "composer-2.5": {
@@ -311,6 +317,10 @@ export const CURSOR_MODEL_CONTEXTS = {
311
317
  "claude-haiku-4-5": {
312
318
  "maxContext": 200000
313
319
  },
320
+ "claude-haiku-5-5": {
321
+ "maxContext": 300000,
322
+ "maxContextForMaxMode": 1000000
323
+ },
314
324
  "claude-opus-4-5": {
315
325
  "maxContext": 200000,
316
326
  "maxContextForMaxMode": 200000
@@ -465,6 +475,9 @@ export const CURSOR_MODEL_CAPABILITIES = {
465
475
  "claude-haiku-4-5": {
466
476
  "supportsImages": true
467
477
  },
478
+ "claude-haiku-5-5": {
479
+ "supportsImages": true
480
+ },
468
481
  "claude-opus-4-5": {
469
482
  "supportsImages": true
470
483
  },
@@ -76,6 +76,13 @@ export declare function decodeAskQuestionQuery(queryBytes: Uint8Array): DecodedA
76
76
  export declare function isCatchAllOptionLabel(label: string): boolean;
77
77
  /** Options as Cursor would display them: trailing catch-all removed. */
78
78
  export declare function displayOptions(question: CursorAskQuestionItem): CursorAskQuestionOption[];
79
+ /** OpenCode `Question.Prompt.header`: short label, max 30 chars. */
80
+ export declare function questionHeader(title: string): string;
81
+ /**
82
+ * Fill required OpenCode `question` fields when a model call omits them.
83
+ * Live OC1 failure: SchemaError Missing key at ["questions"][0]["header"].
84
+ */
85
+ export declare function normalizeOpencodeQuestionArgs(args: Record<string, unknown>): Record<string, unknown>;
79
86
  export type OpencodeQuestionInput = {
80
87
  questions: Array<{
81
88
  question: string;
@@ -100,12 +107,16 @@ export declare function asyncResult(): AskQuestionResultMessage;
100
107
  /**
101
108
  * Pull each question's answer text out of a host `question` tool output.
102
109
  *
103
- * OpenCode returns prose (`metadata.answers` does not cross the AI SDK
110
+ * OpenCode 1.x returns prose (`metadata.answers` does not cross the AI SDK
104
111
  * boundary):
105
112
  *
106
113
  * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
107
114
  * can now continue with the user's answers in mind.
108
115
  *
116
+ * OpenCode 2.0 sends the tool's declared output as JSON
117
+ * (`{ "answers": [["Yes"], …] }`) and does not put that prose on the AI SDK
118
+ * tool-result. Answers are positional.
119
+ *
109
120
  * The prose is located by its `"<question>"="` anchor — robust
110
121
  * against commas, quotes and `"="` inside question or answer text, and against
111
122
  * duplicate questions. Returns undefined for a question whose anchor is
@@ -183,7 +183,8 @@ export function displayOptions(question) {
183
183
  return question.options.slice(0, -1);
184
184
  return question.options.slice();
185
185
  }
186
- function header(title) {
186
+ /** OpenCode `Question.Prompt.header`: short label, max 30 chars. */
187
+ export function questionHeader(title) {
187
188
  const trimmed = title.trim();
188
189
  if (!trimmed)
189
190
  return "Question";
@@ -191,6 +192,40 @@ function header(title) {
191
192
  ? trimmed.slice(0, HEADER_MAX_LENGTH - 1).trimEnd() + "…"
192
193
  : trimmed;
193
194
  }
195
+ /**
196
+ * Fill required OpenCode `question` fields when a model call omits them.
197
+ * Live OC1 failure: SchemaError Missing key at ["questions"][0]["header"].
198
+ */
199
+ export function normalizeOpencodeQuestionArgs(args) {
200
+ if (!Array.isArray(args.questions))
201
+ return args;
202
+ return {
203
+ ...args,
204
+ questions: args.questions.map((item) => {
205
+ if (!item || typeof item !== "object" || Array.isArray(item))
206
+ return item;
207
+ const q = item;
208
+ const prompt = typeof q.question === "string" ? q.question : "";
209
+ const existing = typeof q.header === "string" ? q.header : "";
210
+ const options = Array.isArray(q.options)
211
+ ? q.options.map((opt) => {
212
+ if (!opt || typeof opt !== "object" || Array.isArray(opt))
213
+ return opt;
214
+ const o = opt;
215
+ return {
216
+ ...o,
217
+ ...(o.description === undefined ? { description: "" } : {}),
218
+ };
219
+ })
220
+ : q.options;
221
+ return {
222
+ ...q,
223
+ header: questionHeader(existing.trim() || prompt),
224
+ options,
225
+ };
226
+ }),
227
+ };
228
+ }
194
229
  /**
195
230
  * Build the OpenCode `question` tool input. `Question.Prompt` requires
196
231
  * `question`, `header`, and `options[{label, description}]`; Cursor options
@@ -200,7 +235,7 @@ export function askQuestionToolInput(args) {
200
235
  return {
201
236
  questions: args.questions.map((question) => ({
202
237
  question: question.prompt,
203
- header: header(args.title),
238
+ header: questionHeader(args.title),
204
239
  options: displayOptions(question).map((option) => ({
205
240
  label: option.label,
206
241
  description: "",
@@ -220,18 +255,25 @@ export function asyncResult() {
220
255
  /**
221
256
  * Pull each question's answer text out of a host `question` tool output.
222
257
  *
223
- * OpenCode returns prose (`metadata.answers` does not cross the AI SDK
258
+ * OpenCode 1.x returns prose (`metadata.answers` does not cross the AI SDK
224
259
  * boundary):
225
260
  *
226
261
  * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
227
262
  * can now continue with the user's answers in mind.
228
263
  *
264
+ * OpenCode 2.0 sends the tool's declared output as JSON
265
+ * (`{ "answers": [["Yes"], …] }`) and does not put that prose on the AI SDK
266
+ * tool-result. Answers are positional.
267
+ *
229
268
  * The prose is located by its `"<question>"="` anchor — robust
230
269
  * against commas, quotes and `"="` inside question or answer text, and against
231
270
  * duplicate questions. Returns undefined for a question whose anchor is
232
271
  * absent, which is treated as unanswered rather than guessed at.
233
272
  */
234
273
  export function parseAnswerSegments(questions, output) {
274
+ const fromJson = parseJsonAnswerSegments(output, questions.length);
275
+ if (fromJson)
276
+ return fromJson;
235
277
  const answers = [];
236
278
  let cursor = 0;
237
279
  for (const question of questions) {
@@ -260,6 +302,38 @@ export function parseAnswerSegments(questions, output) {
260
302
  }
261
303
  return answers;
262
304
  }
305
+ function cellText(cell) {
306
+ if (typeof cell === "string")
307
+ return cell;
308
+ if (typeof cell === "number" || typeof cell === "boolean")
309
+ return String(cell);
310
+ if (Array.isArray(cell)) {
311
+ const parts = cell.map((item) => (typeof item === "string" ? item : "")).filter(Boolean);
312
+ return parts.length > 0 ? parts.join(", ") : undefined;
313
+ }
314
+ return undefined;
315
+ }
316
+ /** OpenCode 2 `question` output `{ answers: string[][] }`, or a JSON array of cells. */
317
+ function parseJsonAnswerSegments(output, count) {
318
+ const trimmed = output.trim();
319
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("["))
320
+ return undefined;
321
+ let parsed;
322
+ try {
323
+ parsed = JSON.parse(trimmed);
324
+ }
325
+ catch {
326
+ return undefined;
327
+ }
328
+ const cells = Array.isArray(parsed)
329
+ ? parsed
330
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.answers)
331
+ ? parsed.answers
332
+ : undefined;
333
+ if (!cells)
334
+ return undefined;
335
+ return Array.from({ length: count }, (_, index) => cellText(cells[index]));
336
+ }
263
337
  /** OpenCode's placeholder for a question the user left blank. */
264
338
  const UNANSWERED = "Unanswered";
265
339
  /**
@@ -9,6 +9,7 @@
9
9
  * At a successful TurnEnded it is compacted to the graph reachable from the
10
10
  * latest checkpoint, matching Cursor CLI's conversation-export traversal.
11
11
  */
12
+ import { errorMessage } from "../debug.js";
12
13
  import { collectReachableConversationBlobIds } from "./blob-reachability.js";
13
14
  function hex(b) {
14
15
  let s = "";
@@ -76,7 +77,7 @@ export function inspectConversationBlobGraph(conversationId, checkpoint) {
76
77
  return { count, bytes, complete: true };
77
78
  }
78
79
  catch (error) {
79
- return retainedStats(error instanceof Error ? error.message : String(error));
80
+ return retainedStats(errorMessage(error));
80
81
  }
81
82
  }
82
83
  /** Copy all durable blobs so a completed turn can be persisted atomically. */
@@ -131,7 +132,7 @@ export function compactConversationBlobs(conversationId, checkpoint) {
131
132
  };
132
133
  }
133
134
  catch (error) {
134
- return fallback(error instanceof Error ? error.message : String(error));
135
+ return fallback(errorMessage(error));
135
136
  }
136
137
  }
137
138
  /** Replace the in-memory blob bucket when restoring a persisted conversation. */
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { clearCheckpoint } from "./checkpoint.js";
3
3
  import { clearConversationBlobs } from "./blob-store.js";
4
- import { transferFrozenRequestContext, } from "../context/frozen.js";
4
+ import { transferFrozenRequestContext } from "../context/frozen.js";
5
5
  /**
6
6
  * OpenCode session key → active Cursor conversation_id.
7
7
  *
@@ -11,6 +11,8 @@ export type PersistedConversation = {
11
11
  postCompactionRebase: boolean;
12
12
  hostAgent?: string;
13
13
  systemPromptHash?: string;
14
+ /** JSON-encoded TurnProvenance (what this provider emitted last). */
15
+ turnProvenance?: string;
14
16
  };
15
17
  export type ConversationLoadStatus = "restored" | "missing" | "invalid" | "expired";
16
18
  export type PersistedConversationLoad = {
@@ -47,7 +47,8 @@ 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, host_agent=10, system_prompt_hash=11.
50
+ * post_compaction_rebase=9, host_agent=10, system_prompt_hash=11,
51
+ * turn_provenance_json=12.
51
52
  * Blob: id=1, data=2. Tool: name=1, description=2,
52
53
  * input_schema_json=3, source_name=4.
53
54
  *
@@ -176,6 +177,8 @@ function encodeCacheFile(value) {
176
177
  writer.uint32(fieldTag(10, 2)).string(value.hostAgent);
177
178
  if (value.systemPromptHash)
178
179
  writer.uint32(fieldTag(11, 2)).string(value.systemPromptHash);
180
+ if (value.turnProvenance)
181
+ writer.uint32(fieldTag(12, 2)).string(value.turnProvenance);
179
182
  return { protobufBytes: writer.finish(), requestContextBytes: requestContext.length };
180
183
  }
181
184
  function decodeProtobuf(data) {
@@ -191,6 +194,7 @@ function decodeProtobuf(data) {
191
194
  let postCompactionRebase = false;
192
195
  let hostAgent;
193
196
  let systemPromptHash;
197
+ let turnProvenance;
194
198
  while (reader.pos < reader.len) {
195
199
  const tag = reader.uint32();
196
200
  const wireType = tag & 7;
@@ -250,6 +254,11 @@ function decodeProtobuf(data) {
250
254
  throw new Error("invalid system prompt hash");
251
255
  systemPromptHash = reader.string();
252
256
  break;
257
+ case 12:
258
+ if (wireType !== 2)
259
+ throw new Error("invalid turn provenance");
260
+ turnProvenance = reader.string();
261
+ break;
253
262
  default:
254
263
  reader.skipType(wireType);
255
264
  }
@@ -270,6 +279,7 @@ function decodeProtobuf(data) {
270
279
  postCompactionRebase,
271
280
  ...(hostAgent ? { hostAgent } : {}),
272
281
  ...(systemPromptHash ? { systemPromptHash } : {}),
282
+ ...(turnProvenance ? { turnProvenance } : {}),
273
283
  };
274
284
  }
275
285
  function decodeCacheFile(compressed, expectedSessionKey) {
@@ -7,6 +7,8 @@ export declare function hydrateConversationState(cacheDir: string, sessionKey: s
7
7
  hostAgent?: string;
8
8
  systemPromptHash?: string;
9
9
  } | undefined>;
10
+ /** Restore only turn provenance when its in-memory entry was evicted. */
11
+ export declare function hydrateTurnProvenance(cacheDir: string, sessionKey: string): Promise<void>;
10
12
  /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
11
13
  export declare function persistConversationState(cacheDir: string, input: {
12
14
  sessionKey: string;
@@ -3,6 +3,7 @@ import { getFrozenRequestContext, setFrozenRequestContext, } from "../context/fr
3
3
  import { hasConversationBinding, isActiveConversationBinding, restoreConversationBinding, } from "./conversation-bind.js";
4
4
  import { compactConversationBlobs, restoreConversationBlobs, } from "./blob-store.js";
5
5
  import { getCheckpoint, setCheckpoint } from "./checkpoint.js";
6
+ import { getTurnProvenance, parseTurnProvenance, restoreTurnProvenance, serializeTurnProvenance, } from "./turn-provenance.js";
6
7
  import { deletePersistedConversation, loadPersistedConversation, persistConversation, } from "./conversation-persistence.js";
7
8
  /** Restore one OpenCode session before its conversation binding is resolved. */
8
9
  export async function hydrateConversationState(cacheDir, sessionKey) {
@@ -20,6 +21,9 @@ export async function hydrateConversationState(cacheDir, sessionKey) {
20
21
  setCheckpoint(persisted.conversationId, persisted.checkpoint);
21
22
  restoreConversationBlobs(persisted.conversationId, persisted.blobs);
22
23
  setFrozenRequestContext(persisted.conversationId, persisted.requestContext);
24
+ const provenance = persisted.turnProvenance ? parseTurnProvenance(persisted.turnProvenance) : undefined;
25
+ if (provenance?.conversationId === persisted.conversationId)
26
+ restoreTurnProvenance(sessionKey, provenance);
23
27
  trace(`conversation persistence: restored sessionKey=${sessionKey} ` +
24
28
  `conversationId=${persisted.conversationId} checkpoint=${persisted.checkpoint?.length ?? 0}B ` +
25
29
  `blobs=${persisted.blobs.length}`);
@@ -31,6 +35,17 @@ export async function hydrateConversationState(cacheDir, sessionKey) {
31
35
  ...(persisted.systemPromptHash ? { systemPromptHash: persisted.systemPromptHash } : {}),
32
36
  };
33
37
  }
38
+ /** Restore only turn provenance when its in-memory entry was evicted. */
39
+ export async function hydrateTurnProvenance(cacheDir, sessionKey) {
40
+ if (getTurnProvenance(sessionKey))
41
+ return;
42
+ const persisted = (await loadPersistedConversation(cacheDir, sessionKey)).value;
43
+ if (!persisted?.turnProvenance)
44
+ return;
45
+ const provenance = parseTurnProvenance(persisted.turnProvenance);
46
+ if (provenance?.conversationId === persisted.conversationId)
47
+ restoreTurnProvenance(sessionKey, provenance);
48
+ }
34
49
  /** Persist the complete resumable state only after Cursor confirms TurnEnded. */
35
50
  export async function persistConversationState(cacheDir, input) {
36
51
  // A newer Run may have reset/superseded this conversation while its final
@@ -44,6 +59,7 @@ export async function persistConversationState(cacheDir, input) {
44
59
  const blobCompaction = compactConversationBlobs(input.conversationId, checkpoint);
45
60
  const blobs = blobCompaction.blobs;
46
61
  const requestContext = getFrozenRequestContext(input.conversationId) ?? input.requestContext;
62
+ const provenance = getTurnProvenance(input.sessionKey);
47
63
  await persistConversation(cacheDir, {
48
64
  sessionKey: input.sessionKey,
49
65
  conversationId: input.conversationId,
@@ -54,6 +70,9 @@ export async function persistConversationState(cacheDir, input) {
54
70
  postCompactionRebase: input.postCompactionRebase,
55
71
  hostAgent: input.hostAgent,
56
72
  systemPromptHash: input.systemPromptHash,
73
+ ...(provenance?.conversationId === input.conversationId
74
+ ? { turnProvenance: serializeTurnProvenance(provenance) }
75
+ : {}),
57
76
  });
58
77
  trace(`conversation persistence: saved sessionKey=${input.sessionKey} ` +
59
78
  `conversationId=${input.conversationId} checkpoint=${checkpoint?.length ?? 0}B ` +
@@ -7,10 +7,10 @@
7
7
  * does **not** — plans must stay host-portable so switching models does not
8
8
  * strand users on Cursor-specific paths or frontmatter.
9
9
  *
10
- * Location mirrors OpenCode Session.plan shape, but the project-config segment
11
- * comes from {@link hostPlansDir} / {@link opencodeProjectConfigDirs}
12
- * (the active host's project config dir via the path bridge), never a
13
- * hardcoded OpenCode-only directory name.
10
+ * Location is the host's own: the session plan file when the host defines one
11
+ * (`hostPlanFilePath`, OpenCode 1.x `Session.plan`), otherwise a new
12
+ * `<created>-<slug>.md` under {@link hostPlansDir} (OpenCode 2.0's Plan
13
+ * directory, OpenCode 1.x `<data>/plans`, or an installed path bridge).
14
14
  *
15
15
  * Body is plain markdown (the same shape a plan-mode model would write with
16
16
  * `write`). Cursor YAML frontmatter is never emitted.
@@ -58,46 +58,66 @@ export type CursorPlanStageInput = {
58
58
  * Its tool result is the approval outcome (success = approved,
59
59
  * error = not approved). The result may name the host's own
60
60
  * follow-up approval call. This provider does not invent that prompt.
61
- * - `approve` no such tool, but the host advertises `question`: the provider
62
- * writes the plan itself and then asks the same thing with upstream
63
- * `PlanExitTool`'s own prompt.
64
- * - `ack` nothing to ask with (or a lifecycle turn): write, or for a
65
- * lifecycle turn skip the write, and acknowledge the CLI way.
61
+ * - `ack` nothing to ask with (or a lifecycle turn / CreatePlan outside
62
+ * plan mode): write, or for a lifecycle turn skip the write, and
63
+ * acknowledge the CLI way.
64
+ * - `approve` plan mode is active and the host advertises `question` but not
65
+ * a plan review tool: write the plan, then ask with upstream
66
+ * `PlanExitTool`'s own prompt (OpenCode 1.x / 2.0 without
67
+ * `plan_exit`). Explicit "Yes" is execution approval.
68
+ * - `exit` the host's `plan` agent is active, advertises `plan_exit`, and
69
+ * its own plan file is known: write the plan there and run the
70
+ * host `plan_exit` review. The host owns approval and execution;
71
+ * the turn that carries the result leaves `plan` only on approval.
72
+ * - `defer` the host's `plan` agent owns planning but CreatePlan cannot be
73
+ * carried out yet: an approved switch into it is still pending (it
74
+ * applies after this Run), or its plan file is unknown. Nothing is
75
+ * written; the error tells the model which host workflow follows.
66
76
  *
67
- * The plan is always persisted before any prompt: writing needs no approval,
68
- * executing does.
77
+ * The plan is always persisted before any review: writing needs no approval,
78
+ * executing does, and only the host asks for it.
69
79
  */
70
80
  export type CreatePlanBridge = {
71
81
  kind: "stage";
82
+ } | {
83
+ kind: "ack";
72
84
  } | {
73
85
  kind: "approve";
74
86
  } | {
75
- kind: "ack";
87
+ kind: "defer";
88
+ reason: string;
89
+ } | {
90
+ kind: "exit";
91
+ planPath: string;
76
92
  };
93
+ /** CreatePlan raised before an approved switch into the host plan agent took effect. */
94
+ export declare const CREATE_PLAN_HOST_PLAN_PENDING_REASON: string;
95
+ /** CreatePlan raised while the host's own plan agent owns the plan file and its review. */
96
+ export declare const CREATE_PLAN_HOST_PLAN_WORKFLOW_REASON: string;
97
+ /** Cursor-visible reason when the user wants the plan revised instead of run. */
98
+ export declare const CREATE_PLAN_NOT_APPROVED_REASON: string;
77
99
  /** Upstream `PlanExitTool` wording, with the plan the provider just wrote. */
78
100
  export declare function createPlanApprovalQuestion(planLabel: string): string;
79
101
  export declare const CREATE_PLAN_APPROVAL_HEADER = "Build Agent";
80
- /** Cursor-visible reason when the user wants the plan revised instead of run. */
81
- export declare const CREATE_PLAN_NOT_APPROVED_REASON: string;
82
- /**
83
- * Resolve how this CreatePlan is satisfied. `planModeActive` is the provider's
84
- * own record of an approved Cursor plan/spec mode: the approval prompt guards
85
- * the transition *out of* planning, so a CreatePlan raised outside plan mode is
86
- * written and acknowledged without one.
87
- */
102
+ /** Resolve how this CreatePlan is satisfied (see {@link CreatePlanBridge}). */
88
103
  export declare function resolveCreatePlanBridge(options: {
89
104
  allowTools: boolean;
90
105
  canStage?: boolean;
91
- planModeActive?: boolean;
92
106
  advertised: ReadonlySet<string> | Iterable<string>;
107
+ /** An approved switch into the host `plan` agent waits for this Run to end. */
108
+ hostPlanEntryPending?: boolean;
109
+ /** Host primary agent of this Run, when the host reported it. */
110
+ hostAgent?: string;
111
+ /** Provider-recorded Cursor plan/spec mode (the gate out of planning). */
112
+ planModeActive?: boolean;
113
+ /** The host's own plan file for this session, when the host defines one. */
114
+ hostPlanFile?: string;
93
115
  }): CreatePlanBridge;
94
116
  /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
95
117
  export declare function createPlanApprovalQuestionInput(planLabel: string): OpencodeQuestionInput;
96
118
  /**
97
119
  * True when the emulated approval prompt came back as an explicit "Yes".
98
- * An unanswered, dismissed, or failed prompt keeps the model planning rather
99
- * than silently starting execution. OpenCode prose is anchored on the exact
100
- * prompt text. Every other result shape fails closed.
120
+ * An unanswered, dismissed, or failed prompt keeps the model planning.
101
121
  */
102
122
  export declare function createPlanApproved(output: string, isError: boolean, question: string): boolean;
103
123
  /** Decode a `create_plan_request_query` body, or undefined when unusable. */
@@ -125,7 +145,9 @@ export declare function renderOpencodePlanMarkdown(args: CursorCreatePlanArgs):
125
145
  * Write the plan under {@link hostPlansDir} and return a `file://` URI.
126
146
  * Empty / missing content is the caller's responsibility (empty plan_uri ack).
127
147
  */
128
- export declare function writeOpencodePlanFile(args: CursorCreatePlanArgs, workspaceRoot: string, created?: number): CreatePlanWriteResult;
148
+ export declare function writeOpencodePlanFile(args: CursorCreatePlanArgs, workspaceRoot: string, created?: number,
149
+ /** Exact host-owned plan file; default is a new file under {@link hostPlansDir}. */
150
+ target?: string): CreatePlanWriteResult;
129
151
  /**
130
152
  * The plan as the user reads it before approving execution.
131
153
  *
@@ -7,10 +7,10 @@
7
7
  * does **not** — plans must stay host-portable so switching models does not
8
8
  * strand users on Cursor-specific paths or frontmatter.
9
9
  *
10
- * Location mirrors OpenCode Session.plan shape, but the project-config segment
11
- * comes from {@link hostPlansDir} / {@link opencodeProjectConfigDirs}
12
- * (the active host's project config dir via the path bridge), never a
13
- * hardcoded OpenCode-only directory name.
10
+ * Location is the host's own: the session plan file when the host defines one
11
+ * (`hostPlanFilePath`, OpenCode 1.x `Session.plan`), otherwise a new
12
+ * `<created>-<slug>.md` under {@link hostPlansDir} (OpenCode 2.0's Plan
13
+ * directory, OpenCode 1.x `<data>/plans`, or an installed path bridge).
14
14
  *
15
15
  * Body is plain markdown (the same shape a plan-mode model would write with
16
16
  * `write`). Cursor YAML frontmatter is never emitted.
@@ -19,8 +19,9 @@ import { mkdirSync, writeFileSync } from "node:fs";
19
19
  import path from "node:path";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { hostPlansDir } from "../context/paths.js";
22
- import { parseAnswerSegments, } from "./ask-question.js";
23
22
  import { decodeMessageSparse } from "./messages.js";
23
+ import { errorMessage } from "../debug.js";
24
+ import { parseAnswerSegments, } from "./ask-question.js";
24
25
  const PLAN_ADJECTIVES = [
25
26
  "brave",
26
27
  "calm",
@@ -89,6 +90,17 @@ const PLAN_NOUNS = [
89
90
  export const CURSOR_PLAN_STAGE_TOOL = "cursor_plan_stage";
90
91
  /** Held InteractionQuery continuation field for a native host plan stage. */
91
92
  export const CREATE_PLAN_RESULT_FIELD = "create_plan_request_response";
93
+ /** CreatePlan raised before an approved switch into the host plan agent took effect. */
94
+ export const CREATE_PLAN_HOST_PLAN_PENDING_REASON = "Plan mode starts in the host's plan agent after this turn. Do not record the plan " +
95
+ "now and make no further tool calls: end this turn. Planning continues in the next " +
96
+ "turn under the host's plan-mode instructions.";
97
+ /** CreatePlan raised while the host's own plan agent owns the plan file and its review. */
98
+ export const CREATE_PLAN_HOST_PLAN_WORKFLOW_REASON = "This host's plan agent owns the plan file and its approval. Write the plan where the " +
99
+ "plan-mode instructions say, then call `plan_exit` to ask the user to approve it. Do " +
100
+ "not implement until it is approved.";
101
+ /** Cursor-visible reason when the user wants the plan revised instead of run. */
102
+ export const CREATE_PLAN_NOT_APPROVED_REASON = "The user did not approve executing this plan. Keep planning: refine the plan and "
103
+ + "propose it again when it is ready.";
92
104
  /** Upstream `PlanExitTool` wording, with the plan the provider just wrote. */
93
105
  export function createPlanApprovalQuestion(planLabel) {
94
106
  const where = planLabel.trim();
@@ -99,28 +111,28 @@ export function createPlanApprovalQuestion(planLabel) {
99
111
  export const CREATE_PLAN_APPROVAL_HEADER = "Build Agent";
100
112
  const CREATE_PLAN_APPROVAL_YES = "Yes";
101
113
  const CREATE_PLAN_APPROVAL_NO = "No";
102
- /** Cursor-visible reason when the user wants the plan revised instead of run. */
103
- export const CREATE_PLAN_NOT_APPROVED_REASON = "The user did not approve executing this plan. Keep planning: refine the plan and "
104
- + "propose it again when it is ready.";
105
- /**
106
- * Resolve how this CreatePlan is satisfied. `planModeActive` is the provider's
107
- * own record of an approved Cursor plan/spec mode: the approval prompt guards
108
- * the transition *out of* planning, so a CreatePlan raised outside plan mode is
109
- * written and acknowledged without one.
110
- */
114
+ /** Resolve how this CreatePlan is satisfied (see {@link CreatePlanBridge}). */
111
115
  export function resolveCreatePlanBridge(options) {
112
116
  // A lifecycle turn (title generation, compaction) runs alongside the real one
113
117
  // and must not write a second plan file or raise a second prompt.
114
118
  if (!options.allowTools)
115
119
  return { kind: "ack" };
120
+ if (options.hostPlanEntryPending) {
121
+ return { kind: "defer", reason: CREATE_PLAN_HOST_PLAN_PENDING_REASON };
122
+ }
116
123
  if (options.canStage)
117
124
  return { kind: "stage" };
118
125
  const names = options.advertised instanceof Set ? options.advertised : new Set(options.advertised);
119
- if (options.planModeActive && names.has("question"))
126
+ if (options.hostAgent === "plan" && names.has("plan_exit")) {
127
+ return options.hostPlanFile
128
+ ? { kind: "exit", planPath: options.hostPlanFile }
129
+ : { kind: "defer", reason: CREATE_PLAN_HOST_PLAN_WORKFLOW_REASON };
130
+ }
131
+ const planModeActive = options.planModeActive === true || options.hostAgent === "plan";
132
+ if (planModeActive && names.has("question"))
120
133
  return { kind: "approve" };
121
134
  return { kind: "ack" };
122
135
  }
123
- /** The synthetic AskQuestion item backing the emulated approval prompt. */
124
136
  function createPlanApprovalItem(question) {
125
137
  return {
126
138
  id: "create_plan_approval",
@@ -155,16 +167,13 @@ export function createPlanApprovalQuestionInput(planLabel) {
155
167
  }
156
168
  /**
157
169
  * True when the emulated approval prompt came back as an explicit "Yes".
158
- * An unanswered, dismissed, or failed prompt keeps the model planning rather
159
- * than silently starting execution. OpenCode prose is anchored on the exact
160
- * prompt text. Every other result shape fails closed.
170
+ * An unanswered, dismissed, or failed prompt keeps the model planning.
161
171
  */
162
172
  export function createPlanApproved(output, isError, question) {
163
173
  if (isError)
164
174
  return false;
165
175
  const [segment] = parseAnswerSegments([createPlanApprovalItem(question)], output);
166
- const answer = (segment ?? "").trim().toLowerCase();
167
- return answer === CREATE_PLAN_APPROVAL_YES.toLowerCase();
176
+ return (segment ?? "").trim().toLowerCase() === CREATE_PLAN_APPROVAL_YES.toLowerCase();
168
177
  }
169
178
  function asRecord(value) {
170
179
  return value && typeof value === "object" && !Array.isArray(value)
@@ -333,18 +342,20 @@ export function renderOpencodePlanMarkdown(args) {
333
342
  * Write the plan under {@link hostPlansDir} and return a `file://` URI.
334
343
  * Empty / missing content is the caller's responsibility (empty plan_uri ack).
335
344
  */
336
- export function writeOpencodePlanFile(args, workspaceRoot, created = Date.now()) {
345
+ export function writeOpencodePlanFile(args, workspaceRoot, created = Date.now(),
346
+ /** Exact host-owned plan file; default is a new file under {@link hostPlansDir}. */
347
+ target) {
337
348
  const markdown = renderOpencodePlanMarkdown(args);
338
349
  if (!markdown.trim()) {
339
350
  return { ok: false, error: "CreatePlan produced no plan content to write" };
340
351
  }
341
- const planPath = resolveHostPlanPath(workspaceRoot, args.name, created);
352
+ const planPath = target ?? resolveHostPlanPath(workspaceRoot, args.name, created);
342
353
  try {
343
354
  mkdirSync(path.dirname(planPath), { recursive: true });
344
355
  writeFileSync(planPath, markdown, "utf-8");
345
356
  }
346
357
  catch (err) {
347
- const message = err instanceof Error ? err.message : String(err);
358
+ const message = errorMessage(err);
348
359
  return { ok: false, error: `Failed to write plan file: ${message}` };
349
360
  }
350
361
  return {