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
@@ -1,14 +1,17 @@
1
1
  import { encodeMessage, getMessageTypes } from "./messages.js";
2
- import { toolsToDescriptors } from "./tools.js";
3
2
  /**
4
3
  * Seed ConversationStateStructure for the first turn (no checkpoint yet).
5
4
  *
6
- * OpenCode needs a system prompt channel; we put it in root_prompt_messages_json
7
- * as a JSON chat message. After the first checkpoint arrives we stop inventing
8
- * state and echo the server's opaque structure instead (CLI behavior).
5
+ * After the first checkpoint arrives we stop inventing state and echo the
6
+ * server's opaque structure instead (CLI behavior). Compaction resets and
7
+ * rebases also use this seed, with `history` carrying OpenCode's prompt turns
8
+ * so Cursor can continue without the old checkpoint.
9
9
  *
10
- * Compaction resets also use this seed, with `history` carrying OpenCode's
11
- * compacted prompt turns so Cursor can summarize without the old checkpoint.
10
+ * No `system` entry is seeded: Cursor does not follow a client-authored
11
+ * `system` root message, and neither Cursor client writes
12
+ * `root_prompt_messages_json`. Host system context travels as the
13
+ * system-instructions rule in RequestContext (`systemInstructionsRule`), so
14
+ * `system` history entries are dropped here rather than duplicated.
12
15
  *
13
16
  * We deliberately do NOT use `AgentRunRequest.custom_system_prompt` (#8): that
14
17
  * field is the internal `--system-prompt` CLI override and the server rejects
@@ -18,14 +21,8 @@ export function buildSeedConversationState(input) {
18
21
  const root = getMessageTypes();
19
22
  const type = root.lookupType("ConversationStateStructure");
20
23
  const messages = [];
21
- if (input?.systemPrompt && input.systemPrompt.length > 0) {
22
- messages.push(JSON.stringify({ role: "system", content: input.systemPrompt }));
23
- }
24
24
  for (const entry of input?.history ?? []) {
25
- if (!entry.content)
26
- continue;
27
- // Avoid duplicating the system prompt when history also carries one.
28
- if (entry.role === "system" && input?.systemPrompt)
25
+ if (!entry.content || entry.role === "system")
29
26
  continue;
30
27
  messages.push(JSON.stringify({ role: entry.role, content: entry.content }));
31
28
  }
@@ -42,16 +39,16 @@ export function buildSeedConversationState(input) {
42
39
  */
43
40
  export function buildRunRequest(input) {
44
41
  const msgId = input.messageId ?? crypto.randomUUID();
45
- // Advertise opencode's tools on the LIVE path: UserMessageAction.request_context
46
- // (#2). AgentRunRequest.mcp_tools (#4) is prewarm-only / empty on real turns —
47
- // putting tools only there is why the model fell back to native Grep/Read.
48
- const tools = input.tools ?? [];
49
- const mcpTools = input.toolDescriptors ?? (tools.length > 0 ? toolsToDescriptors(tools) : []);
42
+ // Advertise host tools on UserMessageAction.request_context (#2) via slim
43
+ // mcp_meta_tool_options. AgentRunRequest.mcp_tools (#4) stays empty on real
44
+ // turns (CLI prewarm-only). Full defs are session.toolDescriptors + exec #36.
50
45
  const requestContext = input.requestContext;
51
46
  const userMessage = {
52
47
  text: input.text,
53
48
  message_id: msgId,
54
49
  };
50
+ if (input.mode !== undefined)
51
+ userMessage.mode = input.mode;
55
52
  if (input.images?.length) {
56
53
  userMessage.selected_context = {
57
54
  selected_images: input.images.map((image) => ({
@@ -70,10 +67,7 @@ export function buildRunRequest(input) {
70
67
  : { user_message_action: userMessageAction };
71
68
  const conversationState = input.conversationState && input.conversationState.length > 0
72
69
  ? input.conversationState
73
- : buildSeedConversationState({
74
- systemPrompt: input.systemPrompt,
75
- history: input.history,
76
- });
70
+ : buildSeedConversationState({ history: input.history });
77
71
  const runRequest = {
78
72
  conversation_id: input.conversationId,
79
73
  conversation_group_id: input.conversationGroupId ?? input.conversationId,
@@ -88,9 +82,7 @@ export function buildRunRequest(input) {
88
82
  parameters: input.parameterValues ?? [],
89
83
  },
90
84
  conversation_state: conversationState,
91
- // Keep #4 populated too (harmless on real turns; useful for prewarm /
92
- // older server builds that still read it).
93
- mcp_tools: { mcp_tools: mcpTools },
85
+ mcp_tools: { mcp_tools: [] },
94
86
  unknown_flag: 0,
95
87
  field_12: 0,
96
88
  };
@@ -17,18 +17,18 @@
17
17
  * through the Question service and, on "Yes", injects a synthetic user message
18
18
  * carrying `agent: "plan" | "build"`, which `createUserMessage` turns into a
19
19
  * `setAgentModel` primary-agent switch (opencode `tool/plan.ts`,
20
- * `session/prompt.ts`). A provider cannot reach `setAgentModel`, but the two
21
- * observable halves — the approval gate and the behavioural contract — are both
22
- * reachable, so the protocol bridge degrades instead of refusing when the host
23
- * tool is missing. OpenCode 2 additionally exposes `session.switchAgent`; its
20
+ * `session/prompt.ts`). OpenCode 2 exposes `session.switchAgent`; its
24
21
  * entrypoint installs a structural callback that selects the native `plan` or
25
22
  * `build` primary agent after the owning Cursor Run becomes terminal:
26
23
  *
27
24
  * - entering plan/spec needs no host tool at all — approve immediately and let
28
25
  * the injected <system_reminder> carry the contract, exactly as Cursor CLI's
29
26
  * own plan mode is prompt-enforced.
30
- * - leaving plan mode is the gate the user must actually see, so it falls back
31
- * to the host `question` tool and only rejects when that is absent too.
27
+ * - leaving the host's own `plan` agent without its `plan_exit` goes through
28
+ * CreatePlan, whose `question` prompt is the execution approval, so the
29
+ * request is rejected with a pointer there (or, with no `question`, to the
30
+ * user's agent switch); a Cursor-only plan mode has nothing on the host to
31
+ * leave and is approved.
32
32
  *
33
33
  * Resolution is keyed solely on the advertised catalog under canonical
34
34
  * OpenCode tool names — never on any external identity or model id.
@@ -36,7 +36,6 @@
36
36
  * CLI reject reason for user declines: "Mode switch rejected by user"
37
37
  * (chunk-7076/dist/ui.js onSwitchModeReject).
38
38
  */
39
- import { type OpencodeQuestionInput } from "./ask-question.js";
40
39
  /**
41
40
  * `PendingExec.resultField` for a held-open SwitchMode InteractionQuery.
42
41
  * Continuation writes an InteractionResponse, not an ExecClientMessage.
@@ -58,10 +57,16 @@ export declare const MISSING_TARGET_REASON = "Missing targetModeId";
58
57
  * even though the next Run will succeed. CreatePlan already soft-acks the same
59
58
  * case; SwitchMode must match.
60
59
  */
61
- export declare const PLAN_EXIT_UNAVAILABLE_REASON: string;
62
- /** Upstream `PlanExitTool` wording, minus a plan path the provider cannot verify. */
63
- export declare const SWITCH_MODE_EXIT_QUESTION = "Planning is complete. Would you like to switch to the build agent and start implementing?";
64
- export declare const SWITCH_MODE_EXIT_HEADER = "Build Agent";
60
+ /**
61
+ * The host's plan agent is active and offers no `plan_exit`: on such a host
62
+ * the user ends plan mode by switching agents, so the model cannot.
63
+ */
64
+ export declare const PLAN_EXIT_BY_USER_REASON: string;
65
+ /**
66
+ * The host's plan agent is active, offers no `plan_exit`, but can ask: leaving
67
+ * plan mode goes through CreatePlan, whose `question` prompt is the approval.
68
+ */
69
+ export declare const PLAN_EXIT_VIA_CREATE_PLAN_REASON: string;
65
70
  export type SwitchModeHostTool = "plan_enter" | "plan_exit";
66
71
  /**
67
72
  * How a SwitchMode query is satisfied this turn.
@@ -70,7 +75,6 @@ export type SwitchModeHostTool = "plan_enter" | "plan_exit";
70
75
  * - `approve` entering plan mode needs no host tool — approve immediately.
71
76
  * - `ack` lifecycle turn (`allowTools=false`): approve on the wire without
72
77
  * mutating session mode (same soft-ack pattern as CreatePlan).
73
- * - `question` leaving plan mode falls back to the host `question` prompt.
74
78
  * - `reject` nothing can satisfy it; `reason` names the real cause.
75
79
  */
76
80
  export type SwitchModeBridge = {
@@ -80,9 +84,6 @@ export type SwitchModeBridge = {
80
84
  kind: "approve";
81
85
  } | {
82
86
  kind: "ack";
83
- } | {
84
- kind: "question";
85
- input: OpencodeQuestionInput;
86
87
  } | {
87
88
  kind: "reject";
88
89
  reason: string;
@@ -114,21 +115,22 @@ export declare function normalizeSwitchModeId(targetModeId: string): string;
114
115
  * for CLI-shaped behavioral guidance.
115
116
  */
116
117
  export declare function mapSwitchModeTarget(targetModeId: string): SwitchModeMapping;
117
- /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
118
- export declare function switchModeExitQuestionInput(): OpencodeQuestionInput;
119
118
  /**
120
119
  * Resolve how this SwitchMode target is satisfied, keyed only on the advertised
121
120
  * catalog under canonical tool names.
122
121
  *
123
- * A missing plan tool is not a refusal: entering plan mode needs no host tool,
124
- * and leaving it degrades to the `question` prompt so the user still approves
125
- * before any execution starts.
122
+ * A missing plan tool is not a refusal: entering plan mode needs no host tool.
123
+ * Leaving the host's own plan agent without `plan_exit` is approved through
124
+ * CreatePlan (or the user's agent switch when nothing can ask), and a
125
+ * Cursor-only plan mode (no host plan agent) has nothing on the host to leave.
126
126
  */
127
127
  export declare function resolveSwitchModeBridge(targetModeId: string, options: {
128
128
  allowTools: boolean;
129
129
  advertised: ReadonlySet<string> | Iterable<string>;
130
130
  /** Cursor unified mode currently recorded for this session, if any. */
131
131
  activeModeId?: string;
132
+ /** Host primary agent of this Run, when the host reported it. */
133
+ hostAgent?: string;
132
134
  }): SwitchModeBridge;
133
135
  /** Decode a `switch_mode_request_query` body, or undefined when unusable. */
134
136
  export declare function decodeSwitchModeQuery(queryBytes: Uint8Array): DecodedSwitchModeQuery | undefined;
@@ -142,12 +144,6 @@ export declare function switchModeRejectedResult(reason: string): Record<string,
142
144
  * Question/permission declines and empty failures use the CLI user-reject string.
143
145
  */
144
146
  export declare function switchModeResultFromToolOutput(output: string, isError: boolean): Record<string, unknown>;
145
- /**
146
- * Host `question` outcome → Cursor SwitchModeRequestResponse, for the emulated
147
- * plan exit. Only an explicit "Yes" approves; an unanswered or dismissed prompt
148
- * keeps the model in plan mode rather than silently starting execution.
149
- */
150
- export declare function switchModeResultFromQuestionOutput(output: string, isError: boolean): Record<string, unknown>;
151
147
  /** Record the approved Cursor mode for later system-reminder injection. */
152
148
  export declare function setActiveCursorMode(sessionKey: string | undefined, targetModeId: string, options?: {
153
149
  bridgedPlanEntered?: boolean;
@@ -155,8 +151,47 @@ export declare function setActiveCursorMode(sessionKey: string | undefined, targ
155
151
  export declare function getActiveCursorMode(sessionKey: string | undefined): string | undefined;
156
152
  /** True while the provider has an approved Cursor plan/spec mode for this session. */
157
153
  export declare function isCursorPlanModeActive(sessionKey: string | undefined): boolean;
158
- /** True after a bridged plan/spec Run has observed plan_enter removed by the host. */
154
+ /** True while Cursor plan/spec mode is one the host enforces (see `bridgedPlanEntered`). */
159
155
  export declare function isBridgedCursorPlanModeActive(sessionKey: string | undefined): boolean;
156
+ /**
157
+ * Cursor plan mode follows the host's `plan` primary agent, as Cursor CLI's
158
+ * mode follows its own mode switch (Shift+Tab, `/plan`).
159
+ *
160
+ * - A turn under the host plan agent puts Cursor in plan mode, however the
161
+ * host got there (its agent picker, a plan_enter approval).
162
+ * - The host's own exit from that agent (a plan_exit approval, or an agent
163
+ * switch in its UI) ends Cursor plan mode. Only an observed `plan` → other
164
+ * transition counts, so Cursor-only modes (debug, ask, …) under the host's
165
+ * ordinary agent are kept; the next Run then carries the agent-mode
166
+ * reminder instead of a stale "plan mode is still active" one.
167
+ */
168
+ export declare function followHostPlanAgent(sessionKey: string | undefined, previousHostAgent: string | undefined, hostAgent: string | undefined): "entered" | "left" | undefined;
169
+ /**
170
+ * `agent.v1.AgentMode` for a Cursor mode id, as Cursor CLI derives
171
+ * `UserMessage.mode` from its current mode: plan, ask, and debug are their own
172
+ * modes; every other mode (agent, spec, triage, …) runs as agent.
173
+ */
174
+ export declare function cursorAgentModeWireValue(modeId: string | undefined): number;
175
+ /**
176
+ * One-shot translation of the host plan agent's workflow into Cursor terms,
177
+ * delivered once per Cursor conversation while that agent owns the turn.
178
+ *
179
+ * With the host's plan file known, Cursor's native CreatePlan carries out the
180
+ * host instruction "write the plan file, then call plan_exit" (see the `exit`
181
+ * CreatePlan bridge), so the model needs no host tool at all. Without it, the
182
+ * model is given `plan_exit`'s exact call shape from the live schema: the
183
+ * RequestContext overlay is names-only, and otherwise it must look the tool up
184
+ * (or guess its arguments) before it can submit the plan.
185
+ */
186
+ export declare function takeHostPlanAgentNote(sessionKey: string | undefined, conversationId: string, hostAgent: string | undefined, tools: ReadonlyArray<{
187
+ name: string;
188
+ inputSchema?: unknown;
189
+ }>, options?: {
190
+ /** The host's own plan file for this session, when the host defines one. */
191
+ hostPlanFile?: string;
192
+ /** MCP server identity the advertised `plan_exit` is called through. */
193
+ server?: string;
194
+ }): string | undefined;
160
195
  export declare function clearActiveCursorMode(sessionKey: string | undefined): void;
161
196
  export declare function resetActiveCursorModesForTests(): void;
162
197
  /**
@@ -168,6 +203,8 @@ export declare function cursorModeSystemReminder(targetModeId: string, options?:
168
203
  firstTurn?: boolean;
169
204
  planExitAdvertised?: boolean;
170
205
  planStageAdvertised?: boolean;
206
+ /** The host `question` tool is advertised, so CreatePlan asks to start implementing. */
207
+ questionAdvertised?: boolean;
171
208
  }): string | undefined;
172
209
  /**
173
210
  * Consume the active-mode reminder for this OpenCode session.
@@ -175,4 +212,6 @@ export declare function cursorModeSystemReminder(targetModeId: string, options?:
175
212
  */
176
213
  export declare function takeActiveCursorModeReminder(sessionKey: string | undefined, options?: {
177
214
  advertisedTools?: ReadonlySet<string> | Iterable<string>;
215
+ /** Host primary agent of this request; when known it, not the catalog, says whether planning ended. */
216
+ hostAgent?: string;
178
217
  }): string | undefined;
@@ -17,18 +17,18 @@
17
17
  * through the Question service and, on "Yes", injects a synthetic user message
18
18
  * carrying `agent: "plan" | "build"`, which `createUserMessage` turns into a
19
19
  * `setAgentModel` primary-agent switch (opencode `tool/plan.ts`,
20
- * `session/prompt.ts`). A provider cannot reach `setAgentModel`, but the two
21
- * observable halves — the approval gate and the behavioural contract — are both
22
- * reachable, so the protocol bridge degrades instead of refusing when the host
23
- * tool is missing. OpenCode 2 additionally exposes `session.switchAgent`; its
20
+ * `session/prompt.ts`). OpenCode 2 exposes `session.switchAgent`; its
24
21
  * entrypoint installs a structural callback that selects the native `plan` or
25
22
  * `build` primary agent after the owning Cursor Run becomes terminal:
26
23
  *
27
24
  * - entering plan/spec needs no host tool at all — approve immediately and let
28
25
  * the injected <system_reminder> carry the contract, exactly as Cursor CLI's
29
26
  * own plan mode is prompt-enforced.
30
- * - leaving plan mode is the gate the user must actually see, so it falls back
31
- * to the host `question` tool and only rejects when that is absent too.
27
+ * - leaving the host's own `plan` agent without its `plan_exit` goes through
28
+ * CreatePlan, whose `question` prompt is the execution approval, so the
29
+ * request is rejected with a pointer there (or, with no `question`, to the
30
+ * user's agent switch); a Cursor-only plan mode has nothing on the host to
31
+ * leave and is approved.
32
32
  *
33
33
  * Resolution is keyed solely on the advertised catalog under canonical
34
34
  * OpenCode tool names — never on any external identity or model id.
@@ -36,7 +36,7 @@
36
36
  * CLI reject reason for user declines: "Mode switch rejected by user"
37
37
  * (chunk-7076/dist/ui.js onSwitchModeReject).
38
38
  */
39
- import { parseAnswerSegments, } from "./ask-question.js";
39
+ import { CURSOR_PLAN_STAGE_TOOL } from "./create-plan.js";
40
40
  import { decodeMessageSparse } from "./messages.js";
41
41
  /**
42
42
  * `PendingExec.resultField` for a held-open SwitchMode InteractionQuery.
@@ -59,13 +59,18 @@ export const MISSING_TARGET_REASON = "Missing targetModeId";
59
59
  * even though the next Run will succeed. CreatePlan already soft-acks the same
60
60
  * case; SwitchMode must match.
61
61
  */
62
- export const PLAN_EXIT_UNAVAILABLE_REASON = "Neither the OpenCode `plan_exit` tool nor the `question` tool is available to the " +
63
- "current agent, so leaving plan mode cannot be approved this turn.";
64
- /** Upstream `PlanExitTool` wording, minus a plan path the provider cannot verify. */
65
- export const SWITCH_MODE_EXIT_QUESTION = "Planning is complete. Would you like to switch to the build agent and start implementing?";
66
- export const SWITCH_MODE_EXIT_HEADER = "Build Agent";
67
- const SWITCH_MODE_EXIT_YES = "Yes";
68
- const SWITCH_MODE_EXIT_NO = "No";
62
+ /**
63
+ * The host's plan agent is active and offers no `plan_exit`: on such a host
64
+ * the user ends plan mode by switching agents, so the model cannot.
65
+ */
66
+ export const PLAN_EXIT_BY_USER_REASON = "This host leaves plan mode when the user switches from the plan agent to the build " +
67
+ "agent. Ask the user to switch agents when the plan is ready; do not implement before then.";
68
+ /**
69
+ * The host's plan agent is active, offers no `plan_exit`, but can ask: leaving
70
+ * plan mode goes through CreatePlan, whose `question` prompt is the approval.
71
+ */
72
+ export const PLAN_EXIT_VIA_CREATE_PLAN_REASON = "Leave plan mode by recording the finished plan with Cursor CreatePlan: the user is then " +
73
+ "asked whether to switch to the build agent and start implementing. Do not implement before they approve.";
69
74
  const activeCursorModeBySession = new Map();
70
75
  const MAX_ACTIVE_CURSOR_MODES = 256;
71
76
  function asRecord(value) {
@@ -97,46 +102,14 @@ export function mapSwitchModeTarget(targetModeId) {
97
102
  }
98
103
  return { ok: true, toolName: "plan_exit" };
99
104
  }
100
- /** The synthetic AskQuestion item backing a question-emulated plan exit. */
101
- function switchModeExitQuestionItem() {
102
- return {
103
- id: "switch_mode_exit",
104
- prompt: SWITCH_MODE_EXIT_QUESTION,
105
- options: [
106
- { id: "yes", label: SWITCH_MODE_EXIT_YES },
107
- { id: "no", label: SWITCH_MODE_EXIT_NO },
108
- ],
109
- allowMultiple: false,
110
- };
111
- }
112
- /** Host `question` input mirroring upstream `PlanExitTool`'s own prompt. */
113
- export function switchModeExitQuestionInput() {
114
- return {
115
- questions: [
116
- {
117
- question: SWITCH_MODE_EXIT_QUESTION,
118
- header: SWITCH_MODE_EXIT_HEADER,
119
- options: [
120
- {
121
- label: SWITCH_MODE_EXIT_YES,
122
- description: "Switch to build agent and start implementing the plan",
123
- },
124
- {
125
- label: SWITCH_MODE_EXIT_NO,
126
- description: "Stay with plan agent to continue refining the plan",
127
- },
128
- ],
129
- },
130
- ],
131
- };
132
- }
133
105
  /**
134
106
  * Resolve how this SwitchMode target is satisfied, keyed only on the advertised
135
107
  * catalog under canonical tool names.
136
108
  *
137
- * A missing plan tool is not a refusal: entering plan mode needs no host tool,
138
- * and leaving it degrades to the `question` prompt so the user still approves
139
- * before any execution starts.
109
+ * A missing plan tool is not a refusal: entering plan mode needs no host tool.
110
+ * Leaving the host's own plan agent without `plan_exit` is approved through
111
+ * CreatePlan (or the user's agent switch when nothing can ask), and a
112
+ * Cursor-only plan mode (no host plan agent) has nothing on the host to leave.
140
113
  */
141
114
  export function resolveSwitchModeBridge(targetModeId, options) {
142
115
  const mapped = mapSwitchModeTarget(targetModeId);
@@ -162,11 +135,13 @@ export function resolveSwitchModeBridge(targetModeId, options) {
162
135
  // whole contract, exactly as Cursor CLI's own plan mode is prompt-enforced.
163
136
  if (mapped.toolName === "plan_enter")
164
137
  return { kind: "approve" };
165
- // Leaving plan mode starts real work, so it keeps a user-visible gate.
166
- if (names.has("question")) {
167
- return { kind: "question", input: switchModeExitQuestionInput() };
138
+ if (options.hostAgent === "plan") {
139
+ return {
140
+ kind: "reject",
141
+ reason: names.has("question") ? PLAN_EXIT_VIA_CREATE_PLAN_REASON : PLAN_EXIT_BY_USER_REASON,
142
+ };
168
143
  }
169
- return { kind: "reject", reason: PLAN_EXIT_UNAVAILABLE_REASON };
144
+ return { kind: "approve" };
170
145
  }
171
146
  /** Decode a `switch_mode_request_query` body, or undefined when unusable. */
172
147
  export function decodeSwitchModeQuery(queryBytes) {
@@ -222,20 +197,6 @@ export function switchModeResultFromToolOutput(output, isError) {
222
197
  }
223
198
  return switchModeRejectedResult(trimmed);
224
199
  }
225
- /**
226
- * Host `question` outcome → Cursor SwitchModeRequestResponse, for the emulated
227
- * plan exit. Only an explicit "Yes" approves; an unanswered or dismissed prompt
228
- * keeps the model in plan mode rather than silently starting execution.
229
- */
230
- export function switchModeResultFromQuestionOutput(output, isError) {
231
- if (isError)
232
- return switchModeResultFromToolOutput(output, true);
233
- const [segment] = parseAnswerSegments([switchModeExitQuestionItem()], output);
234
- const answer = (segment ?? "").trim().toLowerCase();
235
- if (answer === SWITCH_MODE_EXIT_YES.toLowerCase())
236
- return switchModeApprovedResult();
237
- return switchModeRejectedResult(USER_REJECTED_REASON);
238
- }
239
200
  /** Record the approved Cursor mode for later system-reminder injection. */
240
201
  export function setActiveCursorMode(sessionKey, targetModeId, options = {}) {
241
202
  if (!sessionKey)
@@ -265,13 +226,121 @@ export function isCursorPlanModeActive(sessionKey) {
265
226
  const mode = getActiveCursorMode(sessionKey);
266
227
  return mode === "plan" || mode === "spec";
267
228
  }
268
- /** True after a bridged plan/spec Run has observed plan_enter removed by the host. */
229
+ /** True while Cursor plan/spec mode is one the host enforces (see `bridgedPlanEntered`). */
269
230
  export function isBridgedCursorPlanModeActive(sessionKey) {
270
231
  if (!sessionKey)
271
232
  return false;
272
233
  const state = activeCursorModeBySession.get(sessionKey);
273
234
  return (state?.modeId === "plan" || state?.modeId === "spec") && state.bridgedPlanEntered;
274
235
  }
236
+ /**
237
+ * Cursor plan mode follows the host's `plan` primary agent, as Cursor CLI's
238
+ * mode follows its own mode switch (Shift+Tab, `/plan`).
239
+ *
240
+ * - A turn under the host plan agent puts Cursor in plan mode, however the
241
+ * host got there (its agent picker, a plan_enter approval).
242
+ * - The host's own exit from that agent (a plan_exit approval, or an agent
243
+ * switch in its UI) ends Cursor plan mode. Only an observed `plan` → other
244
+ * transition counts, so Cursor-only modes (debug, ask, …) under the host's
245
+ * ordinary agent are kept; the next Run then carries the agent-mode
246
+ * reminder instead of a stale "plan mode is still active" one.
247
+ */
248
+ export function followHostPlanAgent(sessionKey, previousHostAgent, hostAgent) {
249
+ if (!sessionKey || !hostAgent)
250
+ return undefined;
251
+ if (hostAgent === "plan") {
252
+ if (isBridgedCursorPlanModeActive(sessionKey))
253
+ return undefined;
254
+ // The host runs its own plan agent, so it enforces this plan mode.
255
+ setActiveCursorMode(sessionKey, "plan", { bridgedPlanEntered: true });
256
+ return "entered";
257
+ }
258
+ if (previousHostAgent !== "plan" || !isCursorPlanModeActive(sessionKey))
259
+ return undefined;
260
+ setActiveCursorMode(sessionKey, "agent");
261
+ return "left";
262
+ }
263
+ /**
264
+ * `agent.v1.AgentMode` for a Cursor mode id, as Cursor CLI derives
265
+ * `UserMessage.mode` from its current mode: plan, ask, and debug are their own
266
+ * modes; every other mode (agent, spec, triage, …) runs as agent.
267
+ */
268
+ export function cursorAgentModeWireValue(modeId) {
269
+ switch (normalizeSwitchModeId(modeId ?? "")) {
270
+ case "plan":
271
+ return 3;
272
+ case "chat":
273
+ case "ask":
274
+ case "search":
275
+ return 2;
276
+ case "debug":
277
+ return 4;
278
+ default:
279
+ return 1;
280
+ }
281
+ }
282
+ /** Conversation that already received the host plan-exit call shape, per session. */
283
+ const hostPlanExitNoteBySession = new Map();
284
+ function describeToolArguments(schema) {
285
+ const record = asRecord(schema);
286
+ const properties = asRecord(record?.properties);
287
+ const names = properties ? Object.keys(properties) : [];
288
+ if (names.length === 0)
289
+ return "It takes no arguments: call it with `{}`.";
290
+ const required = new Set(Array.isArray(record?.required) ? record.required.filter((name) => typeof name === "string") : []);
291
+ const lines = names.map((name) => {
292
+ const property = asRecord(properties[name]);
293
+ const type = str(property?.type) || "value";
294
+ const description = str(property?.description).trim();
295
+ return `- \`${name}\` (${type}, ${required.has(name) ? "required" : "optional"})` +
296
+ (description ? `: ${description}` : "");
297
+ });
298
+ return `Its arguments (use exactly these names):\n${lines.join("\n")}`;
299
+ }
300
+ /**
301
+ * One-shot translation of the host plan agent's workflow into Cursor terms,
302
+ * delivered once per Cursor conversation while that agent owns the turn.
303
+ *
304
+ * With the host's plan file known, Cursor's native CreatePlan carries out the
305
+ * host instruction "write the plan file, then call plan_exit" (see the `exit`
306
+ * CreatePlan bridge), so the model needs no host tool at all. Without it, the
307
+ * model is given `plan_exit`'s exact call shape from the live schema: the
308
+ * RequestContext overlay is names-only, and otherwise it must look the tool up
309
+ * (or guess its arguments) before it can submit the plan.
310
+ */
311
+ export function takeHostPlanAgentNote(sessionKey, conversationId, hostAgent, tools, options = {}) {
312
+ if (!sessionKey)
313
+ return undefined;
314
+ if (hostAgent !== "plan") {
315
+ hostPlanExitNoteBySession.delete(sessionKey);
316
+ return undefined;
317
+ }
318
+ const planExit = tools.find((tool) => tool.name === "plan_exit");
319
+ // A host plan-stage tool carries the plan to the host review itself.
320
+ if (tools.some((tool) => tool.name === CURSOR_PLAN_STAGE_TOOL))
321
+ return undefined;
322
+ if (!planExit || hostPlanExitNoteBySession.get(sessionKey) === conversationId)
323
+ return undefined;
324
+ hostPlanExitNoteBySession.delete(sessionKey);
325
+ hostPlanExitNoteBySession.set(sessionKey, conversationId);
326
+ while (hostPlanExitNoteBySession.size > MAX_ACTIVE_CURSOR_MODES) {
327
+ const oldest = hostPlanExitNoteBySession.keys().next().value;
328
+ if (!oldest)
329
+ break;
330
+ hostPlanExitNoteBySession.delete(oldest);
331
+ }
332
+ if (options.hostPlanFile) {
333
+ return wrapReminder("The host plan agent is active. When the plan is ready, record it with CreatePlan. " +
334
+ `CreatePlan saves it as the host's plan file (${options.hostPlanFile}) and submits it ` +
335
+ "to the host's plan review, which asks the user; this carries out the host " +
336
+ "instructions to write the plan file and call plan_exit, so do neither yourself. " +
337
+ "Do not implement until the user approves.");
338
+ }
339
+ return wrapReminder("The host plan agent is active. When the plan file is written and ready for review, " +
340
+ `submit it by calling MCP tool \`plan_exit\` on server \`${options.server ?? "opencode"}\` directly. ` +
341
+ "Its full definition is below, so do not look it up with GetMcpTools first. " +
342
+ describeToolArguments(planExit.inputSchema));
343
+ }
275
344
  export function clearActiveCursorMode(sessionKey) {
276
345
  if (!sessionKey)
277
346
  return;
@@ -279,6 +348,7 @@ export function clearActiveCursorMode(sessionKey) {
279
348
  }
280
349
  export function resetActiveCursorModesForTests() {
281
350
  activeCursorModeBySession.clear();
351
+ hostPlanExitNoteBySession.clear();
282
352
  }
283
353
  function wrapReminder(body) {
284
354
  return `<system_reminder>\n${body.trim()}\n</system_reminder>`;
@@ -295,12 +365,15 @@ export function cursorModeSystemReminder(targetModeId, options = {}) {
295
365
  const first = options.firstTurn !== false;
296
366
  // A host plan-stage tool writes the artifact and its result names the
297
367
  // follow-up that requests approval. plan_exit only leaves plan mode.
298
- // Without either tool, recording the plan is the gate and the provider asks
299
- // whether to start implementing. Naming an unavailable tool would strand the model.
368
+ // Without either, CreatePlan asks through `question` whether to implement;
369
+ // with no `question` either, the user leaves plan mode by switching agents.
370
+ // Naming an unavailable tool would strand the model.
300
371
  const leavePlan = options.planStageAdvertised
301
372
  ? "record the finished plan with Cursor CreatePlan. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until the tool returns success"
302
373
  : options.planExitAdvertised === false
303
- ? "record the finished plan (Cursor CreatePlan). Writing it needs no approval, and the user is then asked whether to start implementing; if they decline, refine the plan and record it again"
374
+ ? options.questionAdvertised
375
+ ? "record the finished plan with Cursor CreatePlan. Writing it needs no approval; the user is then asked whether to switch to the build agent and start implementing. If they decline, refine the plan and record it again"
376
+ : "record the finished plan (Cursor CreatePlan), then tell the user it is ready: they switch to the build agent to have it implemented"
304
377
  : "record the finished plan, then call OpenCode `plan_exit` so the user can approve leaving plan mode";
305
378
  if (id === "plan" || id === "spec") {
306
379
  return wrapReminder(first
@@ -413,7 +486,7 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
413
486
  : new Set(options.advertisedTools)
414
487
  : undefined;
415
488
  const isPlanMode = state.modeId === "plan" || state.modeId === "spec";
416
- if (isPlanMode && state.bridgedPlanEntered && advertised) {
489
+ if (isPlanMode && state.bridgedPlanEntered && advertised && !options.hostAgent) {
417
490
  if (advertised.has("plan_enter")) {
418
491
  activeCursorModeBySession.delete(sessionKey);
419
492
  return cursorModeSystemReminder("agent", { firstTurn: true });
@@ -425,6 +498,7 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
425
498
  ? {
426
499
  planExitAdvertised: advertised.has("plan_exit"),
427
500
  planStageAdvertised: advertised.has("cursor_plan_stage"),
501
+ questionAdvertised: advertised.has("question"),
428
502
  }
429
503
  : {}),
430
504
  });
@@ -15,6 +15,8 @@ export type CursorConversationTokenDetails = {
15
15
  breakdown?: CursorPromptTokenBreakdown;
16
16
  };
17
17
  export type CursorContextUsageSource = "checkpoint-current-run" | "checkpoint-previous-turn";
18
+ /** Cursor can update occupancy while retaining categories from an older checkpoint. */
19
+ export declare function currentCursorTokenBreakdown(details: CursorConversationTokenDetails | undefined): CursorPromptTokenBreakdown | undefined;
18
20
  /**
19
21
  * Read field #5 (`ConversationTokenDetails`) from Cursor's opaque
20
22
  * `ConversationStateStructure` checkpoint without decoding or re-encoding the
@@ -1,4 +1,13 @@
1
1
  import protobuf from "protobufjs";
2
+ /** Cursor can update occupancy while retaining categories from an older checkpoint. */
3
+ export function currentCursorTokenBreakdown(details) {
4
+ const breakdown = details?.breakdown;
5
+ return breakdown
6
+ && breakdown.totalUsedTokens === details.usedTokens
7
+ && breakdown.maxTokens === details.maxTokens
8
+ ? breakdown
9
+ : undefined;
10
+ }
2
11
  const MAX_BREAKDOWN_CATEGORIES = 128;
3
12
  function requireWireType(actual, expected, field) {
4
13
  if (actual !== expected)
@@ -142,6 +151,7 @@ export function cursorContextUsageMetadata(details, source = "checkpoint-current
142
151
  const usedPercent = details.maxTokens > 0
143
152
  ? Math.max(0, Math.min(100, Math.round(details.usedTokens / details.maxTokens * 1_000) / 10))
144
153
  : undefined;
154
+ const breakdown = currentCursorTokenBreakdown(details);
145
155
  return {
146
156
  contextUsageVersion: 2,
147
157
  source,
@@ -150,6 +160,6 @@ export function cursorContextUsageMetadata(details, source = "checkpoint-current
150
160
  maxTokens: details.maxTokens,
151
161
  remainingTokens: Math.max(0, details.maxTokens - details.usedTokens),
152
162
  ...(usedPercent === undefined ? {} : { usedPercent }),
153
- ...(details.breakdown ? { breakdown: details.breakdown } : {}),
163
+ ...(breakdown ? { breakdown } : {}),
154
164
  };
155
165
  }
@@ -22,6 +22,10 @@ export type BridgedOpenCodeToolCall = {
22
22
  };
23
23
  /** Display variants Cursor executes itself (mcp_state + optional write spill). */
24
24
  export declare function isNativeDisplayToolCall(variant: string): boolean;
25
+ /** Server-side dynamic-call failures may complete without a started/exec frame. */
26
+ export declare function displayMcpToolError(toolCall: Record<string, unknown> | undefined): string | undefined;
27
+ /** Native discovery errors are server-side results, never host execution. */
28
+ export declare function displayNativeDiscoveryError(toolCall: Record<string, unknown> | undefined): string | undefined;
25
29
  /**
26
30
  * Normalize a full todo snapshot for `mirroredTodos` storage. Returns
27
31
  * undefined when the input is not a todo array (nothing to learn). Drops the
@@ -69,6 +69,20 @@ export function isNativeDisplayToolCall(variant) {
69
69
  function asRecord(v) {
70
70
  return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
71
71
  }
72
+ /** Server-side dynamic-call failures may complete without a started/exec frame. */
73
+ export function displayMcpToolError(toolCall) {
74
+ const call = asRecord(toolCall?.mcp_tool_call);
75
+ const result = asRecord(call?.result);
76
+ const error = asRecord(result?.error);
77
+ const parts = [error?.error, error?.detail].filter((value) => typeof value === "string" && value.length > 0);
78
+ return parts.length > 0 ? parts.join(": ") : undefined;
79
+ }
80
+ /** Native discovery errors are server-side results, never host execution. */
81
+ export function displayNativeDiscoveryError(toolCall) {
82
+ const call = asRecord(toolCall?.get_mcp_tools_tool_call);
83
+ const error = asRecord(asRecord(call?.result)?.error)?.error;
84
+ return typeof error === "string" && error.length > 0 ? error : undefined;
85
+ }
72
86
  function findToolVariant(toolCall) {
73
87
  for (const key of TOOL_CALL_VARIANTS) {
74
88
  if (toolCall[key] != null)