cursor-opencode-provider 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +22 -13
  2. package/dist/context/agents.d.ts +1 -1
  3. package/dist/context/agents.js +1 -1
  4. package/dist/context/auth-store.js +2 -2
  5. package/dist/context/build.d.ts +3 -1
  6. package/dist/context/build.js +4 -2
  7. package/dist/context/index.d.ts +1 -1
  8. package/dist/context/index.js +1 -1
  9. package/dist/context/paths.d.ts +34 -53
  10. package/dist/context/paths.js +54 -118
  11. package/dist/context/plugins.js +1 -0
  12. package/dist/context/rules.js +2 -1
  13. package/dist/context/skills.js +1 -0
  14. package/dist/image-save-tool.d.ts +19 -0
  15. package/dist/image-save-tool.js +25 -0
  16. package/dist/image-save.d.ts +58 -0
  17. package/dist/image-save.js +189 -0
  18. package/dist/image-staging.d.ts +59 -0
  19. package/dist/image-staging.js +91 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/language-model.d.ts +3 -2
  22. package/dist/language-model.js +589 -41
  23. package/dist/plan-execution-kickoff.d.ts +55 -0
  24. package/dist/plan-execution-kickoff.js +162 -0
  25. package/dist/plugin-opencode2.js +52 -3
  26. package/dist/plugin-v2.d.ts +11 -9
  27. package/dist/plugin-v2.js +6 -13
  28. package/dist/plugin.d.ts +9 -0
  29. package/dist/plugin.js +61 -5
  30. package/dist/pricing-data.d.ts +16 -4
  31. package/dist/pricing-data.js +16 -4
  32. package/dist/protocol/ask-question.d.ts +133 -0
  33. package/dist/protocol/ask-question.js +320 -0
  34. package/dist/protocol/conversation-bind.d.ts +1 -0
  35. package/dist/protocol/conversation-bind.js +3 -0
  36. package/dist/protocol/create-plan.d.ts +141 -0
  37. package/dist/protocol/create-plan.js +373 -0
  38. package/dist/protocol/generate-image.d.ts +54 -0
  39. package/dist/protocol/generate-image.js +101 -0
  40. package/dist/protocol/interactions.d.ts +110 -8
  41. package/dist/protocol/interactions.js +295 -35
  42. package/dist/protocol/messages.js +118 -6
  43. package/dist/protocol/struct.js +1 -1
  44. package/dist/protocol/switch-mode.d.ts +178 -0
  45. package/dist/protocol/switch-mode.js +417 -0
  46. package/dist/protocol/tool-call-bridge.js +10 -5
  47. package/dist/protocol/tools.d.ts +27 -11
  48. package/dist/protocol/tools.js +98 -42
  49. package/dist/usage.d.ts +2 -2
  50. package/dist/usage.js +2 -2
  51. package/dist/web-search-tool.d.ts +22 -26
  52. package/dist/web-search-tool.js +25 -15
  53. package/package.json +7 -4
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cursor-native AskQuestion ⇄ OpenCode `question` translation.
3
+ *
4
+ * Cursor's AskQuestion is not a tool the client executes — the server raises an
5
+ * `InteractionQuery` (`ask_question_interaction_query`, field 3) and blocks the
6
+ * Run until the client answers with an `AskQuestionResult`. OpenCode has an
7
+ * equivalent host tool (`question`), so the provider translates rather than
8
+ * refusing: refusing made models narrate "the AskQuestion tool is unavailable"
9
+ * and fall back to prose instead of ever calling the advertised host tool.
10
+ *
11
+ * Every rule below mirrors Cursor CLI, decompiled at
12
+ * `~/Projects/cursor-mock-server/cursor/cli{,-local}`:
13
+ *
14
+ * - `src/utils/interaction-utils.ts` — `BU` (freeform option id), `Q7` (display
15
+ * options), `iX` (selection → success), `N$` (rejection + default reason).
16
+ * - `chunk-7076/dist/ui.js` (`askQuestionInteractionQuery` case) and
17
+ * `subagent/subagent-prompt-handler.js` (`requestAskQuestion`) — the
18
+ * sync/async split and the exact rejection reasons.
19
+ *
20
+ * Keep the reason strings byte-identical to the CLI's: they reach the model as
21
+ * tool feedback, and Cursor's server-side prompting is tuned against them.
22
+ */
23
+ /** Cursor's synthetic "type your own answer" option id (interaction-utils `BU`). */
24
+ export declare const FREEFORM_OPTION_ID = "__freeform_other__";
25
+ /**
26
+ * `PendingExec.resultField` marking a held-open AskQuestion. It is not an
27
+ * `ExecClientMessage` field: continuation dispatches on it to write an
28
+ * InteractionResponse or a ConversationAction instead of an exec result.
29
+ */
30
+ export declare const ASK_QUESTION_RESULT_FIELD = "ask_question_interaction_response";
31
+ /** Default rejection reason when the user skips (interaction-utils `N$`). */
32
+ export declare const SKIPPED_REASON = "Questions skipped by user";
33
+ /** Rejection used when the host dismisses the prompt (CLI `resolveAskQuestionPrompt`). */
34
+ export declare const DISMISSED_REASON = "Ask-question prompt dismissed";
35
+ export declare const MISSING_QUERY_REASON = "Missing ask-question query";
36
+ export declare const MISSING_ARGS_REASON = "Missing ask-question arguments";
37
+ export type CursorAskQuestionOption = {
38
+ id: string;
39
+ label: string;
40
+ };
41
+ export type CursorAskQuestionItem = {
42
+ id: string;
43
+ prompt: string;
44
+ options: CursorAskQuestionOption[];
45
+ allowMultiple: boolean;
46
+ };
47
+ export type CursorAskQuestionArgs = {
48
+ title: string;
49
+ questions: CursorAskQuestionItem[];
50
+ runAsync: boolean;
51
+ };
52
+ export type DecodedAskQuestionQuery = {
53
+ args: CursorAskQuestionArgs;
54
+ /**
55
+ * The exact `AskQuestionArgs` sub-message bytes. Async completion echoes
56
+ * `original_args` verbatim so fields this schema does not model survive.
57
+ */
58
+ rawArgs: Uint8Array;
59
+ toolCallId: string;
60
+ };
61
+ /** `AskQuestionResult` oneof, shaped for `encodeMessage`. */
62
+ export type AskQuestionResultMessage = Record<string, unknown>;
63
+ /**
64
+ * Decode an `ask_question_interaction_query` body. Returns undefined when the
65
+ * body carries no usable question set — the caller then rejects with the CLI's
66
+ * own "missing" reason rather than bridging an empty prompt to the host.
67
+ */
68
+ export declare function decodeAskQuestionQuery(queryBytes: Uint8Array): DecodedAskQuestionQuery | undefined;
69
+ /**
70
+ * Cursor CLI drops a trailing catch-all option before rendering, because it
71
+ * always appends its own freeform "Other" row (interaction-utils `Q7`).
72
+ * OpenCode's `question` tool does the same thing via `custom` (default true,
73
+ * and not settable from the tool schema), so the strip is required here too or
74
+ * the user sees two "Other" entries for one question.
75
+ */
76
+ export declare function isCatchAllOptionLabel(label: string): boolean;
77
+ /** Options as Cursor would display them: trailing catch-all removed. */
78
+ export declare function displayOptions(question: CursorAskQuestionItem): CursorAskQuestionOption[];
79
+ export type OpencodeQuestionInput = {
80
+ questions: Array<{
81
+ question: string;
82
+ header: string;
83
+ options: Array<{
84
+ label: string;
85
+ description: string;
86
+ }>;
87
+ multiple?: boolean;
88
+ }>;
89
+ };
90
+ /**
91
+ * Build the OpenCode `question` tool input. `Question.Prompt` requires
92
+ * `question`, `header`, and `options[{label, description}]`; Cursor options
93
+ * carry no description, so it stays empty rather than being invented.
94
+ */
95
+ export declare function askQuestionToolInput(args: CursorAskQuestionArgs): OpencodeQuestionInput;
96
+ /** `AskQuestionResult{rejected}` — interaction-utils `N$`. */
97
+ export declare function rejectedResult(reason?: string): AskQuestionResultMessage;
98
+ /** `AskQuestionResult{async}` — the immediate reply for `run_async` queries. */
99
+ export declare function asyncResult(): AskQuestionResultMessage;
100
+ /**
101
+ * Pull each question's answer text out of OpenCode's `question` tool output.
102
+ *
103
+ * The tool returns prose, not structured data (`metadata.answers` does not
104
+ * cross the AI SDK boundary):
105
+ *
106
+ * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
107
+ * can now continue with the user's answers in mind.
108
+ *
109
+ * The provider authored the question strings, so each answer is located by its
110
+ * own `"<question>"="` anchor scanning forward — robust against commas, quotes
111
+ * and `"="` inside question or answer text, and against duplicate questions.
112
+ * Returns undefined for a question whose anchor is absent, which is treated as
113
+ * unanswered rather than guessed at.
114
+ */
115
+ export declare function parseAnswerSegments(questions: readonly CursorAskQuestionItem[], output: string): Array<string | undefined>;
116
+ /**
117
+ * Map one question's answer labels back onto Cursor option ids.
118
+ *
119
+ * OpenCode returns labels, so a label that matches an option we sent becomes a
120
+ * `selected_option_id`; anything else is the user's custom answer and becomes
121
+ * `freeform_text`. Cursor CLI's `iX` substitutes the literal "Other" when the
122
+ * freeform row was chosen without text, so an empty custom answer does too.
123
+ */
124
+ export declare function answerForQuestion(question: CursorAskQuestionItem, segment: string | undefined): Record<string, unknown>;
125
+ /**
126
+ * Translate an OpenCode `question` tool result into an `AskQuestionResult`.
127
+ *
128
+ * A tool error means the host dismissed or failed the prompt — OpenCode's
129
+ * `Question.RejectedError` is the dismissal path — so it maps to the CLI's
130
+ * rejection rather than an empty success, which the model would read as "the
131
+ * user answered nothing".
132
+ */
133
+ export declare function askQuestionResultFromToolOutput(args: CursorAskQuestionArgs, output: string, isError: boolean): AskQuestionResultMessage;
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Cursor-native AskQuestion ⇄ OpenCode `question` translation.
3
+ *
4
+ * Cursor's AskQuestion is not a tool the client executes — the server raises an
5
+ * `InteractionQuery` (`ask_question_interaction_query`, field 3) and blocks the
6
+ * Run until the client answers with an `AskQuestionResult`. OpenCode has an
7
+ * equivalent host tool (`question`), so the provider translates rather than
8
+ * refusing: refusing made models narrate "the AskQuestion tool is unavailable"
9
+ * and fall back to prose instead of ever calling the advertised host tool.
10
+ *
11
+ * Every rule below mirrors Cursor CLI, decompiled at
12
+ * `~/Projects/cursor-mock-server/cursor/cli{,-local}`:
13
+ *
14
+ * - `src/utils/interaction-utils.ts` — `BU` (freeform option id), `Q7` (display
15
+ * options), `iX` (selection → success), `N$` (rejection + default reason).
16
+ * - `chunk-7076/dist/ui.js` (`askQuestionInteractionQuery` case) and
17
+ * `subagent/subagent-prompt-handler.js` (`requestAskQuestion`) — the
18
+ * sync/async split and the exact rejection reasons.
19
+ *
20
+ * Keep the reason strings byte-identical to the CLI's: they reach the model as
21
+ * tool feedback, and Cursor's server-side prompting is tuned against them.
22
+ */
23
+ import { decodeMessageSparse } from "./messages.js";
24
+ /** Cursor's synthetic "type your own answer" option id (interaction-utils `BU`). */
25
+ export const FREEFORM_OPTION_ID = "__freeform_other__";
26
+ /**
27
+ * `PendingExec.resultField` marking a held-open AskQuestion. It is not an
28
+ * `ExecClientMessage` field: continuation dispatches on it to write an
29
+ * InteractionResponse or a ConversationAction instead of an exec result.
30
+ */
31
+ export const ASK_QUESTION_RESULT_FIELD = "ask_question_interaction_response";
32
+ /** Default rejection reason when the user skips (interaction-utils `N$`). */
33
+ export const SKIPPED_REASON = "Questions skipped by user";
34
+ /** Rejection used when the host dismisses the prompt (CLI `resolveAskQuestionPrompt`). */
35
+ export const DISMISSED_REASON = "Ask-question prompt dismissed";
36
+ export const MISSING_QUERY_REASON = "Missing ask-question query";
37
+ export const MISSING_ARGS_REASON = "Missing ask-question arguments";
38
+ /** OpenCode's `question` tool caps its short header label at 30 chars. */
39
+ const HEADER_MAX_LENGTH = 30;
40
+ function asRecord(value) {
41
+ return value && typeof value === "object" && !Array.isArray(value)
42
+ ? value
43
+ : undefined;
44
+ }
45
+ function str(value) {
46
+ return typeof value === "string" ? value : "";
47
+ }
48
+ /**
49
+ * Extract the raw bytes of `AskQuestionInteractionQuery.args` (#1) without
50
+ * decoding. protobufjs hands back a decoded copy that would silently drop
51
+ * unknown fields on re-encode; the async completion action must echo the
52
+ * server's own bytes.
53
+ */
54
+ function extractArgsBytes(queryBytes) {
55
+ let offset = 0;
56
+ while (offset < queryBytes.length) {
57
+ let key = 0;
58
+ let shift = 0;
59
+ while (offset < queryBytes.length) {
60
+ const byte = queryBytes[offset++];
61
+ key |= (byte & 0x7f) << shift;
62
+ if ((byte & 0x80) === 0)
63
+ break;
64
+ shift += 7;
65
+ if (shift > 28)
66
+ return undefined;
67
+ }
68
+ const fieldNumber = key >>> 3;
69
+ const wireType = key & 7;
70
+ if (wireType === 2) {
71
+ let length = 0;
72
+ let lengthShift = 0;
73
+ while (offset < queryBytes.length) {
74
+ const byte = queryBytes[offset++];
75
+ length |= (byte & 0x7f) << lengthShift;
76
+ if ((byte & 0x80) === 0)
77
+ break;
78
+ lengthShift += 7;
79
+ if (lengthShift > 28)
80
+ return undefined;
81
+ }
82
+ if (offset + length > queryBytes.length)
83
+ return undefined;
84
+ if (fieldNumber === 1)
85
+ return queryBytes.subarray(offset, offset + length);
86
+ offset += length;
87
+ }
88
+ else if (wireType === 0) {
89
+ while (offset < queryBytes.length && (queryBytes[offset++] & 0x80) !== 0) {
90
+ // skip varint continuation bytes
91
+ }
92
+ }
93
+ else if (wireType === 5) {
94
+ offset += 4;
95
+ }
96
+ else if (wireType === 1) {
97
+ offset += 8;
98
+ }
99
+ else {
100
+ return undefined;
101
+ }
102
+ }
103
+ return undefined;
104
+ }
105
+ /**
106
+ * Decode an `ask_question_interaction_query` body. Returns undefined when the
107
+ * body carries no usable question set — the caller then rejects with the CLI's
108
+ * own "missing" reason rather than bridging an empty prompt to the host.
109
+ */
110
+ export function decodeAskQuestionQuery(queryBytes) {
111
+ let decoded;
112
+ try {
113
+ decoded = decodeMessageSparse("AskQuestionInteractionQuery", queryBytes);
114
+ }
115
+ catch {
116
+ return undefined;
117
+ }
118
+ const rawArgs = extractArgsBytes(queryBytes);
119
+ const argsRecord = asRecord(decoded.args);
120
+ if (!argsRecord || !rawArgs)
121
+ return undefined;
122
+ const questions = Array.isArray(argsRecord.questions)
123
+ ? argsRecord.questions.flatMap((raw) => {
124
+ const item = asRecord(raw);
125
+ if (!item)
126
+ return [];
127
+ const prompt = str(item.prompt);
128
+ if (!prompt)
129
+ return [];
130
+ const options = Array.isArray(item.options)
131
+ ? item.options.flatMap((rawOption) => {
132
+ const option = asRecord(rawOption);
133
+ if (!option)
134
+ return [];
135
+ const label = str(option.label);
136
+ if (!label)
137
+ return [];
138
+ return [{ id: str(option.id), label }];
139
+ })
140
+ : [];
141
+ return [{
142
+ id: str(item.id),
143
+ prompt,
144
+ options,
145
+ allowMultiple: item.allow_multiple === true,
146
+ }];
147
+ })
148
+ : [];
149
+ if (questions.length === 0)
150
+ return undefined;
151
+ return {
152
+ args: {
153
+ title: str(argsRecord.title),
154
+ questions,
155
+ runAsync: argsRecord.run_async === true,
156
+ },
157
+ rawArgs,
158
+ toolCallId: str(decoded.tool_call_id),
159
+ };
160
+ }
161
+ /**
162
+ * Cursor CLI drops a trailing catch-all option before rendering, because it
163
+ * always appends its own freeform "Other" row (interaction-utils `Q7`).
164
+ * OpenCode's `question` tool does the same thing via `custom` (default true,
165
+ * and not settable from the tool schema), so the strip is required here too or
166
+ * the user sees two "Other" entries for one question.
167
+ */
168
+ export function isCatchAllOptionLabel(label) {
169
+ const normalized = label.toLowerCase().trim();
170
+ return (normalized === "other"
171
+ || normalized === "something else"
172
+ || normalized.startsWith("other:")
173
+ || normalized.startsWith("other -")
174
+ || normalized.startsWith("other (")
175
+ || normalized.startsWith("something else:")
176
+ || normalized.startsWith("something else -")
177
+ || normalized.startsWith("something else ("));
178
+ }
179
+ /** Options as Cursor would display them: trailing catch-all removed. */
180
+ export function displayOptions(question) {
181
+ const last = question.options.at(-1);
182
+ if (last && isCatchAllOptionLabel(last.label))
183
+ return question.options.slice(0, -1);
184
+ return question.options.slice();
185
+ }
186
+ function header(title) {
187
+ const trimmed = title.trim();
188
+ if (!trimmed)
189
+ return "Question";
190
+ return trimmed.length > HEADER_MAX_LENGTH
191
+ ? trimmed.slice(0, HEADER_MAX_LENGTH - 1).trimEnd() + "…"
192
+ : trimmed;
193
+ }
194
+ /**
195
+ * Build the OpenCode `question` tool input. `Question.Prompt` requires
196
+ * `question`, `header`, and `options[{label, description}]`; Cursor options
197
+ * carry no description, so it stays empty rather than being invented.
198
+ */
199
+ export function askQuestionToolInput(args) {
200
+ return {
201
+ questions: args.questions.map((question) => ({
202
+ question: question.prompt,
203
+ header: header(args.title),
204
+ options: displayOptions(question).map((option) => ({
205
+ label: option.label,
206
+ description: "",
207
+ })),
208
+ ...(question.allowMultiple ? { multiple: true } : {}),
209
+ })),
210
+ };
211
+ }
212
+ /** `AskQuestionResult{rejected}` — interaction-utils `N$`. */
213
+ export function rejectedResult(reason) {
214
+ return { rejected: { reason: reason ?? SKIPPED_REASON } };
215
+ }
216
+ /** `AskQuestionResult{async}` — the immediate reply for `run_async` queries. */
217
+ export function asyncResult() {
218
+ return { async: {} };
219
+ }
220
+ /**
221
+ * Pull each question's answer text out of OpenCode's `question` tool output.
222
+ *
223
+ * The tool returns prose, not structured data (`metadata.answers` does not
224
+ * cross the AI SDK boundary):
225
+ *
226
+ * User has answered your questions: "<q1>"="<a, b>", "<q2>"="Unanswered". You
227
+ * can now continue with the user's answers in mind.
228
+ *
229
+ * The provider authored the question strings, so each answer is located by its
230
+ * own `"<question>"="` anchor scanning forward — robust against commas, quotes
231
+ * and `"="` inside question or answer text, and against duplicate questions.
232
+ * Returns undefined for a question whose anchor is absent, which is treated as
233
+ * unanswered rather than guessed at.
234
+ */
235
+ export function parseAnswerSegments(questions, output) {
236
+ const answers = [];
237
+ let cursor = 0;
238
+ for (const question of questions) {
239
+ const anchor = `"${question.prompt}"="`;
240
+ const start = output.indexOf(anchor, cursor);
241
+ if (start < 0) {
242
+ answers.push(undefined);
243
+ continue;
244
+ }
245
+ const valueStart = start + anchor.length;
246
+ // The value ends at the last quote before the next anchor (or the trailing
247
+ // `. You can now continue…`). Searching backwards from the next anchor
248
+ // keeps quoted text inside an answer intact.
249
+ const nextAnchorStart = questions
250
+ .map((other) => output.indexOf(`"${other.prompt}"="`, valueStart))
251
+ .filter((index) => index > valueStart)
252
+ .reduce((min, index) => (min < 0 || index < min ? index : min), -1);
253
+ const searchEnd = nextAnchorStart >= 0 ? nextAnchorStart : output.length;
254
+ const valueEnd = output.lastIndexOf('"', searchEnd - 1);
255
+ if (valueEnd < valueStart) {
256
+ answers.push(undefined);
257
+ continue;
258
+ }
259
+ answers.push(output.slice(valueStart, valueEnd));
260
+ cursor = valueEnd;
261
+ }
262
+ return answers;
263
+ }
264
+ /** OpenCode's placeholder for a question the user left blank. */
265
+ const UNANSWERED = "Unanswered";
266
+ /**
267
+ * Map one question's answer labels back onto Cursor option ids.
268
+ *
269
+ * OpenCode returns labels, so a label that matches an option we sent becomes a
270
+ * `selected_option_id`; anything else is the user's custom answer and becomes
271
+ * `freeform_text`. Cursor CLI's `iX` substitutes the literal "Other" when the
272
+ * freeform row was chosen without text, so an empty custom answer does too.
273
+ */
274
+ export function answerForQuestion(question, segment) {
275
+ const answer = { question_id: question.id };
276
+ if (segment === undefined || segment.trim().length === 0 || segment.trim() === UNANSWERED) {
277
+ return answer;
278
+ }
279
+ const byLabel = new Map(displayOptions(question).map((option) => [option.label.trim().toLowerCase(), option.id]));
280
+ const selected = [];
281
+ const custom = [];
282
+ // `multiple: true` answers arrive joined with ", " (question tool `formatted`).
283
+ for (const piece of segment.split(", ")) {
284
+ const label = piece.trim();
285
+ if (!label)
286
+ continue;
287
+ const optionId = byLabel.get(label.toLowerCase());
288
+ if (optionId !== undefined)
289
+ selected.push(optionId);
290
+ else
291
+ custom.push(label);
292
+ }
293
+ if (selected.length > 0)
294
+ answer.selected_option_ids = selected;
295
+ if (custom.length > 0)
296
+ answer.freeform_text = custom.join(", ");
297
+ else if (selected.length === 0)
298
+ answer.freeform_text = "Other";
299
+ return answer;
300
+ }
301
+ /**
302
+ * Translate an OpenCode `question` tool result into an `AskQuestionResult`.
303
+ *
304
+ * A tool error means the host dismissed or failed the prompt — OpenCode's
305
+ * `Question.RejectedError` is the dismissal path — so it maps to the CLI's
306
+ * rejection rather than an empty success, which the model would read as "the
307
+ * user answered nothing".
308
+ */
309
+ export function askQuestionResultFromToolOutput(args, output, isError) {
310
+ if (isError) {
311
+ const reason = output.trim();
312
+ return rejectedResult(reason.length > 0 ? reason : DISMISSED_REASON);
313
+ }
314
+ const segments = parseAnswerSegments(args.questions, output);
315
+ return {
316
+ success: {
317
+ answers: args.questions.map((question, index) => answerForQuestion(question, segments[index])),
318
+ },
319
+ };
320
+ }
@@ -24,6 +24,7 @@ export declare function peekConversationId(sessionKey: string): string;
24
24
  */
25
25
  export declare function bindConversationId(sessionKey: string | undefined, opts?: {
26
26
  reset?: boolean;
27
+ ephemeral?: boolean;
27
28
  }): {
28
29
  conversationId: string;
29
30
  reset: boolean;
@@ -76,6 +76,9 @@ export function peekConversationId(sessionKey) {
76
76
  * mint a new id so TurnEnded cache_read cannot keep overflowing OpenCode.
77
77
  */
78
78
  export function bindConversationId(sessionKey, opts) {
79
+ if (opts?.ephemeral) {
80
+ return { conversationId: crypto.randomUUID(), reset: false };
81
+ }
79
82
  if (!sessionKey) {
80
83
  return { conversationId: crypto.randomUUID(), reset: !!opts?.reset };
81
84
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Cursor-native CreatePlan → host-default plan file.
3
+ *
4
+ * Cursor raises `create_plan_request_query` (#7) with CreatePlanArgs and expects
5
+ * a CreatePlanResult carrying `plan_uri`. Cursor CLI writes under
6
+ * `~/.cursor/plans/*.plan.md` with YAML frontmatter; this provider deliberately
7
+ * does **not** — plans must stay host-portable so switching models does not
8
+ * strand users on Cursor-specific paths or frontmatter.
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.
14
+ *
15
+ * Body is plain markdown (the same shape a plan-mode model would write with
16
+ * `write`). Cursor YAML frontmatter is never emitted.
17
+ */
18
+ import { type OpencodeQuestionInput } from "./ask-question.js";
19
+ export type CursorPlanTodo = {
20
+ id: string;
21
+ content: string;
22
+ status: string;
23
+ };
24
+ export type CursorCreatePlanArgs = {
25
+ plan: string;
26
+ overview: string;
27
+ name: string;
28
+ isProject: boolean;
29
+ todos: CursorPlanTodo[];
30
+ };
31
+ export type DecodedCreatePlanQuery = {
32
+ args: CursorCreatePlanArgs;
33
+ toolCallId: string;
34
+ };
35
+ export type CreatePlanWriteResult = {
36
+ ok: true;
37
+ planPath: string;
38
+ planUri: string;
39
+ markdown: string;
40
+ } | {
41
+ ok: false;
42
+ error: string;
43
+ };
44
+ /** Optional host plan-stage tool advertised by a compatible host. */
45
+ export declare const CURSOR_PLAN_STAGE_TOOL = "cursor_plan_stage";
46
+ /** Held InteractionQuery continuation field for a native host plan stage. */
47
+ export declare const CREATE_PLAN_RESULT_FIELD = "create_plan_request_response";
48
+ export type CursorPlanStageInput = {
49
+ plan_uri: string;
50
+ content: string;
51
+ title: string;
52
+ };
53
+ /**
54
+ * How a CreatePlan query is satisfied this turn. Keyed only on the advertised
55
+ * catalog and the provider's own plan-mode state — never on host identity.
56
+ *
57
+ * - `stage` a host plan-stage tool is advertised; the host writes the plan
58
+ * *and* owns the execution-approval prompt, and its tool result is
59
+ * the approval outcome (success = approved, error = not approved).
60
+ * - `approve` no such tool, but the host advertises `question`: the provider
61
+ * writes the plan itself and then asks the same thing with upstream
62
+ * `PlanExitTool`'s own prompt.
63
+ * - `ack` nothing to ask with (or a lifecycle turn): write, or for a
64
+ * lifecycle turn skip the write, and acknowledge the CLI way.
65
+ *
66
+ * The plan is always persisted before any prompt: writing needs no approval,
67
+ * executing does.
68
+ */
69
+ export type CreatePlanBridge = {
70
+ kind: "stage";
71
+ } | {
72
+ kind: "approve";
73
+ } | {
74
+ kind: "ack";
75
+ };
76
+ /** Upstream `PlanExitTool` wording, with the plan the provider just wrote. */
77
+ export declare function createPlanApprovalQuestion(planLabel: string): string;
78
+ export declare const CREATE_PLAN_APPROVAL_HEADER = "Build Agent";
79
+ /** Cursor-visible reason when the user wants the plan revised instead of run. */
80
+ export declare const CREATE_PLAN_NOT_APPROVED_REASON: string;
81
+ /**
82
+ * Resolve how this CreatePlan is satisfied. `planModeActive` is the provider's
83
+ * own record of an approved Cursor plan/spec mode: the approval prompt guards
84
+ * the transition *out of* planning, so a CreatePlan raised outside plan mode is
85
+ * written and acknowledged without one.
86
+ */
87
+ export declare function resolveCreatePlanBridge(options: {
88
+ allowTools: boolean;
89
+ canStage?: boolean;
90
+ planModeActive?: boolean;
91
+ advertised: ReadonlySet<string> | Iterable<string>;
92
+ }): CreatePlanBridge;
93
+ /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
94
+ export declare function createPlanApprovalQuestionInput(planLabel: string): OpencodeQuestionInput;
95
+ /**
96
+ * True when the emulated approval prompt came back as an explicit "Yes".
97
+ * An unanswered, dismissed, or failed prompt keeps the model planning rather
98
+ * than silently starting execution.
99
+ *
100
+ * `question` must be the exact prompt that was asked — the host echoes it back
101
+ * verbatim, and it is the anchor the answer is parsed from.
102
+ */
103
+ export declare function createPlanApproved(output: string, isError: boolean, question: string): boolean;
104
+ /** Decode a `create_plan_request_query` body, or undefined when unusable. */
105
+ export declare function decodeCreatePlanQuery(queryBytes: Uint8Array): DecodedCreatePlanQuery | undefined;
106
+ /** OpenCode-style adjective-noun slug (core/src/util/slug.ts). */
107
+ export declare function randomPlanSlug(seed?: number): string;
108
+ /** Slugify a human title into a filesystem-safe token. */
109
+ export declare function slugifyPlanName(name: string): string;
110
+ /** Build the advertised host plan-stage artifact payload. */
111
+ export declare function createPlanStageInput(args: CursorCreatePlanArgs): CursorPlanStageInput;
112
+ /**
113
+ * Resolve the absolute plan file path via {@link hostPlansDir}.
114
+ * Filename shape matches OpenCode Session.plan: `<created>-<slug>.md`.
115
+ */
116
+ export declare function resolveHostPlanPath(workspaceRoot: string, name?: string, created?: number): string;
117
+ /** @deprecated Prefer {@link resolveHostPlanPath}. */
118
+ export declare const resolveOpencodePlanPath: typeof resolveHostPlanPath;
119
+ /**
120
+ * Render plain markdown for the plan file. No Cursor YAML frontmatter.
121
+ * Prefer `args.plan` as the body; prepend a title/overview when useful; append
122
+ * a markdown checklist for todos.
123
+ */
124
+ export declare function renderOpencodePlanMarkdown(args: CursorCreatePlanArgs): string;
125
+ /**
126
+ * Write the plan under {@link hostPlansDir} and return a `file://` URI.
127
+ * Empty / missing content is the caller's responsibility (empty plan_uri ack).
128
+ */
129
+ export declare function writeOpencodePlanFile(args: CursorCreatePlanArgs, workspaceRoot: string, created?: number): CreatePlanWriteResult;
130
+ /**
131
+ * The plan as the user reads it before approving execution.
132
+ *
133
+ * Cursor routes the plan body through the interaction query rather than the
134
+ * text stream, so without this the user is asked to approve a plan they were
135
+ * never shown. It goes into the assistant message — the host renders markdown
136
+ * there, it scrolls, and it survives answering the prompt. It must not go into
137
+ * the question itself: OpenCode renders the question dock outside its
138
+ * scrollbox with `flexShrink={0}`, so a full plan there would push the
139
+ * conversation off screen.
140
+ */
141
+ export declare function renderPlanReviewMessage(markdown: string, planPath: string): string;