cursor-opencode-provider 0.8.0 → 0.8.3

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 (132) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/README.md +230 -44
  3. package/SECURITY.md +60 -12
  4. package/dist/activity.d.ts +73 -1
  5. package/dist/activity.js +231 -5
  6. package/dist/agent-url.js +37 -9
  7. package/dist/auth-renewal.d.ts +1 -1
  8. package/dist/auth-renewal.js +58 -26
  9. package/dist/billing.d.ts +105 -0
  10. package/dist/billing.js +212 -0
  11. package/dist/classic-tool-results.d.ts +1 -0
  12. package/dist/classic-tool-results.js +42 -0
  13. package/dist/context/build.d.ts +23 -4
  14. package/dist/context/build.js +65 -10
  15. package/dist/context/dynamic-catalog.js +6 -1
  16. package/dist/context/env.js +27 -3
  17. package/dist/context/epoch.js +9 -5
  18. package/dist/context/frozen.js +6 -2
  19. package/dist/context/git.d.ts +5 -2
  20. package/dist/context/git.js +17 -7
  21. package/dist/context/index.d.ts +2 -0
  22. package/dist/context/index.js +2 -0
  23. package/dist/context/paths.d.ts +45 -18
  24. package/dist/context/paths.js +72 -22
  25. package/dist/context/skills-bridge.d.ts +22 -0
  26. package/dist/context/skills-bridge.js +20 -0
  27. package/dist/context/skills.d.ts +34 -0
  28. package/dist/context/skills.js +211 -0
  29. package/dist/debug.js +13 -2
  30. package/dist/early-tool-rows.d.ts +60 -0
  31. package/dist/early-tool-rows.js +96 -0
  32. package/dist/errors.d.ts +49 -7
  33. package/dist/errors.js +163 -8
  34. package/dist/host-agent-mode.d.ts +53 -4
  35. package/dist/host-agent-mode.js +117 -13
  36. package/dist/host-interrupt.d.ts +12 -0
  37. package/dist/host-interrupt.js +18 -0
  38. package/dist/host-plan-file.d.ts +28 -0
  39. package/dist/host-plan-file.js +76 -0
  40. package/dist/host-steer.d.ts +71 -0
  41. package/dist/host-steer.js +232 -0
  42. package/dist/image-input.d.ts +18 -0
  43. package/dist/image-input.js +122 -30
  44. package/dist/image-save.d.ts +10 -2
  45. package/dist/image-save.js +33 -21
  46. package/dist/index.d.ts +2 -2
  47. package/dist/language-model.d.ts +149 -30
  48. package/dist/language-model.js +3127 -673
  49. package/dist/lost-request.d.ts +39 -0
  50. package/dist/lost-request.js +111 -0
  51. package/dist/model-config.d.ts +12 -1
  52. package/dist/model-config.js +78 -30
  53. package/dist/models.d.ts +26 -3
  54. package/dist/models.js +101 -45
  55. package/dist/opencode2/catalog.d.ts +1 -0
  56. package/dist/opencode2/catalog.js +2 -0
  57. package/dist/opencode2/image-save-tool.d.ts +8 -0
  58. package/dist/opencode2/image-save-tool.js +84 -0
  59. package/dist/opencode2/tool-results.d.ts +18 -0
  60. package/dist/opencode2/tool-results.js +66 -0
  61. package/dist/opencode2/types.d.ts +15 -0
  62. package/dist/parallel-step.d.ts +65 -0
  63. package/dist/parallel-step.js +102 -0
  64. package/dist/plugin-opencode2.js +233 -65
  65. package/dist/plugin.js +97 -23
  66. package/dist/pricing-data.d.ts +40 -3
  67. package/dist/pricing-data.js +40 -3
  68. package/dist/pricing.d.ts +1 -1
  69. package/dist/pricing.js +7 -4
  70. package/dist/process-shared.d.ts +14 -0
  71. package/dist/process-shared.js +18 -0
  72. package/dist/protocol/apply-patch.d.ts +25 -13
  73. package/dist/protocol/apply-patch.js +79 -30
  74. package/dist/protocol/ask-question.d.ts +12 -1
  75. package/dist/protocol/ask-question.js +77 -3
  76. package/dist/protocol/blob-store.d.ts +5 -1
  77. package/dist/protocol/blob-store.js +10 -6
  78. package/dist/protocol/client-version.js +6 -4
  79. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  80. package/dist/protocol/conversation-cache-header.js +149 -0
  81. package/dist/protocol/conversation-persistence.d.ts +27 -1
  82. package/dist/protocol/conversation-persistence.js +628 -126
  83. package/dist/protocol/conversation-state.d.ts +8 -1
  84. package/dist/protocol/conversation-state.js +28 -7
  85. package/dist/protocol/create-plan.d.ts +47 -25
  86. package/dist/protocol/create-plan.js +33 -23
  87. package/dist/protocol/host-notes.d.ts +31 -0
  88. package/dist/protocol/host-notes.js +85 -0
  89. package/dist/protocol/interactions.d.ts +26 -17
  90. package/dist/protocol/interactions.js +80 -31
  91. package/dist/protocol/messages.js +164 -21
  92. package/dist/protocol/request.d.ts +40 -19
  93. package/dist/protocol/request.js +55 -28
  94. package/dist/protocol/sent-history-images.d.ts +49 -0
  95. package/dist/protocol/sent-history-images.js +102 -0
  96. package/dist/protocol/switch-mode.d.ts +66 -27
  97. package/dist/protocol/switch-mode.js +147 -73
  98. package/dist/protocol/token-details.d.ts +2 -0
  99. package/dist/protocol/token-details.js +11 -1
  100. package/dist/protocol/tool-call-bridge.d.ts +6 -0
  101. package/dist/protocol/tool-call-bridge.js +24 -0
  102. package/dist/protocol/tools.d.ts +90 -9
  103. package/dist/protocol/tools.js +543 -151
  104. package/dist/protocol/turn-provenance.d.ts +19 -4
  105. package/dist/protocol/turn-provenance.js +77 -20
  106. package/dist/replay-safety.d.ts +22 -4
  107. package/dist/replay-safety.js +86 -27
  108. package/dist/retry-budget.d.ts +21 -0
  109. package/dist/retry-budget.js +94 -0
  110. package/dist/runtime-files.d.ts +23 -0
  111. package/dist/runtime-files.js +581 -0
  112. package/dist/session.d.ts +129 -10
  113. package/dist/session.js +198 -35
  114. package/dist/shared.d.ts +1 -1
  115. package/dist/shared.js +1 -1
  116. package/dist/shell-timeout.d.ts +54 -10
  117. package/dist/shell-timeout.js +362 -92
  118. package/dist/stream-write.d.ts +20 -0
  119. package/dist/stream-write.js +87 -0
  120. package/dist/terminal-runner.d.ts +15 -0
  121. package/dist/terminal-runner.js +528 -0
  122. package/dist/todo-store.d.ts +2 -0
  123. package/dist/todo-store.js +19 -0
  124. package/dist/transport/connect.d.ts +10 -1
  125. package/dist/transport/connect.js +124 -27
  126. package/dist/usage.d.ts +37 -17
  127. package/dist/usage.js +85 -34
  128. package/package.json +5 -3
  129. package/dist/plan-execution-kickoff.d.ts +0 -57
  130. package/dist/plan-execution-kickoff.js +0 -186
  131. package/dist/protocol/thinking.d.ts +0 -15
  132. package/dist/protocol/thinking.js +0 -17
package/dist/errors.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { APICallError } from "@ai-sdk/provider";
1
2
  /** Structured provider failure with retry and transport diagnostics. */
2
3
  export class CursorProviderError extends Error {
3
4
  origin;
@@ -14,6 +15,16 @@ export class CursorProviderError extends Error {
14
15
  rstCode;
15
16
  code;
16
17
  retryAfterMs;
18
+ /** Cursor's classified verdict, or false once provider retries must stop. */
19
+ retryable;
20
+ /**
21
+ * The provider stopped, but the host may send the turn again: the failed
22
+ * attempt only showed text or reasoning, before any checkpoint. Cursor CLI
23
+ * resends the same action in this case.
24
+ */
25
+ hostRetryable;
26
+ /** What the failed attempt did that a replay inside the Run would repeat. */
27
+ replayBarrier;
17
28
  constructor(message, options) {
18
29
  super(message, options.cause === undefined ? undefined : { cause: options.cause });
19
30
  this.name = "CursorProviderError";
@@ -25,8 +36,82 @@ export class CursorProviderError extends Error {
25
36
  this.rstCode = options.rstCode;
26
37
  this.code = options.code;
27
38
  this.retryAfterMs = options.retryAfterMs;
39
+ this.retryable = options.retryable;
40
+ this.hostRetryable = options.hostRetryable;
28
41
  }
29
42
  }
43
+ /** Connect error code → HTTP status, per https://connectrpc.com/docs/protocol/#error-codes. */
44
+ const CONNECT_CODE_HTTP_STATUS = {
45
+ canceled: 499,
46
+ unknown: 500,
47
+ invalid_argument: 400,
48
+ deadline_exceeded: 504,
49
+ not_found: 404,
50
+ already_exists: 409,
51
+ permission_denied: 403,
52
+ resource_exhausted: 429,
53
+ failed_precondition: 400,
54
+ aborted: 409,
55
+ out_of_range: 400,
56
+ unimplemented: 501,
57
+ internal: 500,
58
+ unavailable: 503,
59
+ data_loss: 500,
60
+ unauthenticated: 401,
61
+ };
62
+ /** Status for a final failure whose Connect code maps to 5xx (internal, unknown, …). */
63
+ const NON_RETRYABLE_SERVER_STATUS = 400;
64
+ /**
65
+ * The error the host receives. Like Cursor CLI, a request Cursor declared not
66
+ * retryable (see `cursorCliRetryable`), a spent/unsafe provider retry, or an
67
+ * authentication failure ends the turn: it becomes an AI SDK `APICallError`
68
+ * with `isRetryable: false` and the
69
+ * conventional `x-should-retry: false` header, which OpenCode 2 checks first
70
+ * (it retries a failure with no status or recognized code). OpenCode 1.x
71
+ * ignores both and retries any status >= 500 or a message matching its retry
72
+ * patterns, so the status is the Connect code's HTTP status capped below 500
73
+ * (Cursor CLI decides from the verdict, never the code) and the message is
74
+ * sanitized. The original code stays on `cause`.
75
+ *
76
+ * A `hostRetryable` failure is the opposite: Cursor CLI would send the same
77
+ * action again, but output the host already shows stops the provider from
78
+ * doing so. It becomes an `APICallError` with `isRetryable: true`, which
79
+ * OpenCode 1.x retries as the same step, and `x-should-retry: true` without a
80
+ * status, which OpenCode 2 continues after the shown output.
81
+ */
82
+ export function hostFacingError(error) {
83
+ if (!(error instanceof Error))
84
+ return new Error(String(error));
85
+ const auth = error instanceof CursorAuthError;
86
+ if (!auth && error instanceof CursorProviderError && error.hostRetryable) {
87
+ return new APICallError({
88
+ message: error.message,
89
+ url: "cursor://agent.v1.AgentService/Run",
90
+ requestBodyValues: {},
91
+ responseHeaders: {
92
+ "x-should-retry": "true",
93
+ ...(error.retryAfterMs === undefined ? {} : { "retry-after-ms": String(error.retryAfterMs) }),
94
+ },
95
+ isRetryable: true,
96
+ cause: error,
97
+ });
98
+ }
99
+ if (!auth && (!(error instanceof CursorProviderError) || error.retryable !== false))
100
+ return error;
101
+ const connectStatus = error.code !== undefined && Object.hasOwn(CONNECT_CODE_HTTP_STATUS, error.code)
102
+ ? CONNECT_CODE_HTTP_STATUS[error.code]
103
+ : CONNECT_CODE_HTTP_STATUS.unknown;
104
+ const status = auth ? (error.statusCode === 403 ? 403 : 401) : connectStatus;
105
+ return new APICallError({
106
+ message: sanitizeHostTerminalMessage(error.message),
107
+ url: "cursor://agent.v1.AgentService/Run",
108
+ requestBodyValues: {},
109
+ statusCode: status >= 500 ? NON_RETRYABLE_SERVER_STATUS : status,
110
+ responseHeaders: { "x-should-retry": "false" },
111
+ isRetryable: false,
112
+ cause: error,
113
+ });
114
+ }
30
115
  export class CursorLocalCancellationError extends CursorProviderError {
31
116
  constructor(message = "Cursor request cancelled locally", cause) {
32
117
  super(message, {
@@ -92,22 +177,53 @@ export function isRejectedCredentialError(error) {
92
177
  isUnauthenticatedStatus(error.code));
93
178
  }
94
179
  /**
95
- * OpenCode's SessionRetry treats several substrings in provider error messages
96
- * as always-retryable — notably bare "unavailable" and "exhausted", plus a
97
- * broader regex set (resource_exhausted, service unavailable, …).
98
- *
99
- * When this provider has already spent its Run retry budget, or has decided a
100
- * replay would be unsafe, the host-facing message must not re-arm that loop.
101
- * Keep the real gRPC/HTTP code on structured fields (`code`, `grpcStatus`).
180
+ * OpenCode 1.x SessionRetry retries an error whose message matches these
181
+ * patterns even when it is marked non-retryable (`RETRYABLE_MESSAGE_PATTERNS`
182
+ * and the `exhausted` / `unavailable` / `too_many_requests` substrings in
183
+ * upstream `packages/opencode/src/session/retry.ts`, v1.19). The status
184
+ * patterns match anywhere, so "$1500" or "500k" also re-arm it.
185
+ */
186
+ const HOST_RETRY_MESSAGE_PATTERNS = [
187
+ /429|500|502|503|504|524/gi,
188
+ /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests|too_many_requests/gi,
189
+ /overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/gi,
190
+ /terminated|fetch failed|failed to fetch|network[-_\s]error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/gi,
191
+ /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/gi,
192
+ /try your request again|retry your request|resource exhausted|resource_exhausted/gi,
193
+ /\btry again (?:later|in\b)|\b(?:currently|temporarily) at capacity\b/gi,
194
+ /exhausted|unavailable/gi,
195
+ ];
196
+ /** Invisible U+2060 WORD JOINER: the text reads the same but no longer matches. */
197
+ const WORD_JOINER = "\u2060";
198
+ /**
199
+ * When this provider has already spent its Run retry budget, decided a replay
200
+ * would be unsafe, or Cursor declared the request not retryable, the
201
+ * host-facing message must not re-arm OpenCode's SessionRetry loop. Common
202
+ * trigger words get readable replacements; any remaining match (status-like
203
+ * digits, Cursor's own wording) is split with a word joiner. Keep the real
204
+ * gRPC/HTTP code on structured fields (`code`, `grpcStatus`).
102
205
  */
103
206
  export function sanitizeHostTerminalMessage(message) {
104
- return message
207
+ let text = message
105
208
  .replace(/\bresource[_\s-]?exhausted\b/gi, "capacity_limit")
106
209
  .replace(/\bservice[_\s-]?unavailable\b/gi, "capacity_limit")
107
210
  .replace(/\bunavailable\b/gi, "capacity_limit")
108
211
  .replace(/\boverloaded\b/gi, "capacity_limit")
109
212
  .replace(/\bretry exhausted\b/gi, "retries finished")
110
213
  .replace(/\bexhausted\b/gi, "finished");
214
+ // A joiner can add a `\b` boundary, so repeat until nothing matches.
215
+ for (let pass = 0; pass < 4; pass++) {
216
+ let changed = false;
217
+ for (const pattern of HOST_RETRY_MESSAGE_PATTERNS) {
218
+ text = text.replace(pattern, (match) => {
219
+ changed = true;
220
+ return `${match[0]}${WORD_JOINER}${match.slice(1)}`;
221
+ });
222
+ }
223
+ if (!changed)
224
+ break;
225
+ }
226
+ return text;
111
227
  }
112
228
  export class CursorRetryExhaustedError extends CursorProviderError {
113
229
  attempts;
@@ -118,6 +234,7 @@ export class CursorRetryExhaustedError extends CursorProviderError {
118
234
  origin: last.origin,
119
235
  transient: false,
120
236
  replaySafe: false,
237
+ retryable: false,
121
238
  statusCode: last.statusCode,
122
239
  grpcStatus: last.grpcStatus,
123
240
  rstCode: last.rstCode,
@@ -227,12 +344,50 @@ export function toCursorProviderError(error, options = { replaySafe: false }) {
227
344
  cause: error,
228
345
  });
229
346
  }
347
+ /**
348
+ * A transient failure after the attempt showed only text or reasoning, before
349
+ * any checkpoint of the turn. Cursor CLI sends the same action again; the host
350
+ * does that here (see `hostFacingError`), and the lost-request record carries
351
+ * the user's request once.
352
+ */
353
+ export function hostRetryError(cause, beforeOutput = false) {
354
+ return new CursorProviderError(beforeOutput
355
+ ? `${cause.message}; the Run could not start and the turn can be sent again`
356
+ : `${cause.message} after visible output, before Cursor saved a checkpoint; the turn can be sent again`, {
357
+ origin: cause.origin,
358
+ transient: false,
359
+ replaySafe: false,
360
+ hostRetryable: true,
361
+ statusCode: cause.statusCode,
362
+ grpcStatus: cause.grpcStatus,
363
+ rstCode: cause.rstCode,
364
+ code: cause.code,
365
+ retryAfterMs: cause.retryAfterMs,
366
+ cause,
367
+ });
368
+ }
369
+ /**
370
+ * Cursor CLI stops automatic resumes after two resumed attempts that streamed
371
+ * but saved no checkpoint, so the turn does not repeat the same work forever.
372
+ */
373
+ export function noProgressResumeError(cause, resumes) {
374
+ return new CursorProviderError(`Cursor Run stopped after ${resumes} resume attempts made no progress; ` +
375
+ "send a message to continue from the last saved state", {
376
+ origin: cause.origin,
377
+ transient: false,
378
+ replaySafe: false,
379
+ retryable: false,
380
+ code: cause.code,
381
+ cause,
382
+ });
383
+ }
230
384
  export function retrySuppressedError(cause, reason, attempt, maxAttempts) {
231
385
  const subject = sanitizeHostTerminalMessage(cause.message.replace(/^Cursor Run stream /, "Cursor stream "));
232
386
  return new CursorProviderError(`${subject} ${reason}; automatic retry unsafe (attempt ${attempt}/${maxAttempts})`, {
233
387
  origin: cause.origin,
234
388
  transient: false,
235
389
  replaySafe: false,
390
+ retryable: false,
236
391
  statusCode: cause.statusCode,
237
392
  grpcStatus: cause.grpcStatus,
238
393
  rstCode: cause.rstCode,
@@ -4,19 +4,68 @@
4
4
  * The language model stays host-neutral: it records a canonical Cursor mode
5
5
  * (`plan`, `spec`, `agent`, ...). A host entrypoint may install this structural
6
6
  * callback when its public API can select the corresponding primary agent.
7
- * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents;
8
- * OpenCode 1.x continues to use advertised `plan_enter` / `plan_exit` tools.
7
+ * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
8
+ * and continues the turn via `session.synthetic` (OpenCode 2 `switchAgent`
9
+ * only publishes the agent and would otherwise idle).
10
+ * On OpenCode 1.x hosts, advertised `plan_enter` / `plan_exit` tools stay
11
+ * authoritative; without `plan_enter`, the classic entrypoint selects the
12
+ * `plan` agent the 1.x way, with a synthetic user message for that agent.
9
13
  */
10
14
  export type HostAgentModeSwitchInput = {
11
15
  sessionID: string;
12
16
  targetModeID: string;
13
17
  /** Concrete Cursor Run that owns the switch. */
14
18
  cursorSessionID?: string;
19
+ /** Host primary agent the owning Run executed under, when the host reported it. */
20
+ hostAgent?: string;
15
21
  };
16
22
  export type HostAgentModeSwitchFn = (input: HostAgentModeSwitchInput) => void | Promise<void>;
17
- export declare function setHostAgentModeSwitch(fn: HostAgentModeSwitchFn | undefined): void;
18
- /** Queue only when this host installed a native primary-agent switch. */
23
+ export type HostAgentModeSwitchOptions = {
24
+ /** The switch also starts the host turn that continues the conversation. */
25
+ resumesTurn?: boolean;
26
+ /** Queue-time filter: a request the switch would not apply stays provider-owned. */
27
+ accepts?: (input: HostAgentModeSwitchInput) => boolean;
28
+ };
29
+ /** Synthetic user text that carries an OpenCode 1.x primary-agent switch. */
30
+ export declare function hostAgentSwitchPromptText(agent: "plan" | "build"): string;
31
+ /**
32
+ * OpenCode 1.x shape of the switch: a 1.x session runs each turn under the
33
+ * agent of its latest user message, which is how the host's own plan_enter /
34
+ * plan_exit change agents.
35
+ *
36
+ * It accepts only a real transition of a session running one of the host's
37
+ * own primary agents: entering an existing `plan` agent from another primary
38
+ * agent, or leaving the observed `plan` agent. Subagent and hidden internal
39
+ * sessions (title, summary, compaction, task children) never get a user turn
40
+ * injected.
41
+ */
42
+ export declare function createPromptHostAgentModeSwitch(prompt: (input: {
43
+ sessionID: string;
44
+ agent: "plan" | "build";
45
+ text: string;
46
+ }) => Promise<unknown>, primaryAgents: () => ReadonlySet<string> | undefined): {
47
+ apply: HostAgentModeSwitchFn;
48
+ accepts: (input: HostAgentModeSwitchInput) => boolean;
49
+ };
50
+ /**
51
+ * Install this host's native switch; it is used until a newer one is
52
+ * installed. The returned disposer removes only this switch, so disposing an
53
+ * older setup never removes a newer setup's switch. `undefined` removes all.
54
+ */
55
+ export declare function setHostAgentModeSwitch(fn: HostAgentModeSwitchFn | undefined, options?: HostAgentModeSwitchOptions): () => void;
56
+ /**
57
+ * How this host applies a Cursor mode switch to its primary agents: `resumes`
58
+ * when the switch also starts the next host turn, `next-turn` when the user's
59
+ * next message runs under the new agent, undefined without a native switch.
60
+ */
61
+ export declare function hostAgentModeSwitchKind(): "resumes" | "next-turn" | undefined;
62
+ /** Queue only when this host installed a native primary-agent switch that accepts the request. */
19
63
  export declare function queueHostAgentModeSwitch(input: HostAgentModeSwitchInput): boolean;
64
+ /**
65
+ * True while an approved switch into the host `plan` agent waits for its Run to
66
+ * end and will itself start the plan-agent turn that continues the work.
67
+ */
68
+ export declare function isHostPlanEntryPending(sessionID: string | undefined): boolean;
20
69
  export declare function cancelHostAgentModeSwitch(sessionID: string | undefined): void;
21
70
  /**
22
71
  * Apply the switch only after its Cursor Run is terminal and owns no pending
@@ -4,28 +4,111 @@
4
4
  * The language model stays host-neutral: it records a canonical Cursor mode
5
5
  * (`plan`, `spec`, `agent`, ...). A host entrypoint may install this structural
6
6
  * callback when its public API can select the corresponding primary agent.
7
- * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents;
8
- * OpenCode 1.x continues to use advertised `plan_enter` / `plan_exit` tools.
7
+ * OpenCode 2.0 uses it to select its vendor-maintained `plan` / `build` agents
8
+ * and continues the turn via `session.synthetic` (OpenCode 2 `switchAgent`
9
+ * only publishes the agent and would otherwise idle).
10
+ * On OpenCode 1.x hosts, advertised `plan_enter` / `plan_exit` tools stay
11
+ * authoritative; without `plan_enter`, the classic entrypoint selects the
12
+ * `plan` agent the 1.x way, with a synthetic user message for that agent.
9
13
  */
10
14
  import { errorMessage, trace } from "./debug.js";
11
- let switchHostAgent;
15
+ /**
16
+ * Live switches, newest last. A host may set the plugin up more than once in
17
+ * one process (one setup per location instance, re-setup on plugin reload) and
18
+ * dispose an older setup after a newer one installed its switch.
19
+ */
20
+ const registrations = [];
21
+ function activeSwitch() {
22
+ return registrations[registrations.length - 1];
23
+ }
12
24
  const pending = new Map();
25
+ const applying = new Map();
13
26
  const MAX_PENDING_HOST_AGENT_SWITCHES = 256;
14
- export function setHostAgentModeSwitch(fn) {
15
- switchHostAgent = fn;
16
- if (!fn)
27
+ /** True for Cursor mode ids served by the host's `plan` primary agent. */
28
+ function isPlanModeID(targetModeID) {
29
+ const mode = targetModeID.trim().toLowerCase();
30
+ return mode === "plan" || mode === "spec";
31
+ }
32
+ /** Synthetic user text that carries an OpenCode 1.x primary-agent switch. */
33
+ export function hostAgentSwitchPromptText(agent) {
34
+ return agent === "plan"
35
+ ? "Plan mode is now active. Continue planning the request above under the plan-mode " +
36
+ "instructions, and do not implement anything until the plan is approved."
37
+ : "Plan mode has ended. Continue with the approved work.";
38
+ }
39
+ /**
40
+ * OpenCode 1.x shape of the switch: a 1.x session runs each turn under the
41
+ * agent of its latest user message, which is how the host's own plan_enter /
42
+ * plan_exit change agents.
43
+ *
44
+ * It accepts only a real transition of a session running one of the host's
45
+ * own primary agents: entering an existing `plan` agent from another primary
46
+ * agent, or leaving the observed `plan` agent. Subagent and hidden internal
47
+ * sessions (title, summary, compaction, task children) never get a user turn
48
+ * injected.
49
+ */
50
+ export function createPromptHostAgentModeSwitch(prompt, primaryAgents) {
51
+ return {
52
+ accepts: ({ targetModeID, hostAgent }) => {
53
+ const agents = primaryAgents();
54
+ const current = hostAgent?.trim();
55
+ if (!agents || !current || !agents.has(current))
56
+ return false;
57
+ return isPlanModeID(targetModeID) ? current !== "plan" && agents.has("plan") : current === "plan";
58
+ },
59
+ apply: async ({ sessionID, targetModeID }) => {
60
+ const agent = isPlanModeID(targetModeID) ? "plan" : "build";
61
+ await prompt({ sessionID, agent, text: hostAgentSwitchPromptText(agent) });
62
+ },
63
+ };
64
+ }
65
+ /**
66
+ * Install this host's native switch; it is used until a newer one is
67
+ * installed. The returned disposer removes only this switch, so disposing an
68
+ * older setup never removes a newer setup's switch. `undefined` removes all.
69
+ */
70
+ export function setHostAgentModeSwitch(fn, options = {}) {
71
+ if (!fn) {
72
+ registrations.length = 0;
17
73
  pending.clear();
74
+ return () => { };
75
+ }
76
+ const registration = { fn, options };
77
+ registrations.push(registration);
78
+ return () => {
79
+ const index = registrations.lastIndexOf(registration);
80
+ if (index >= 0)
81
+ registrations.splice(index, 1);
82
+ if (registrations.length === 0)
83
+ pending.clear();
84
+ };
18
85
  }
19
- /** Queue only when this host installed a native primary-agent switch. */
86
+ /**
87
+ * How this host applies a Cursor mode switch to its primary agents: `resumes`
88
+ * when the switch also starts the next host turn, `next-turn` when the user's
89
+ * next message runs under the new agent, undefined without a native switch.
90
+ */
91
+ export function hostAgentModeSwitchKind() {
92
+ const active = activeSwitch();
93
+ if (!active)
94
+ return undefined;
95
+ return active.options.resumesTurn === true ? "resumes" : "next-turn";
96
+ }
97
+ /** Queue only when this host installed a native primary-agent switch that accepts the request. */
20
98
  export function queueHostAgentModeSwitch(input) {
21
99
  const sessionID = input.sessionID.trim();
22
100
  const targetModeID = input.targetModeID.trim();
23
- if (!switchHostAgent || !sessionID || !targetModeID)
101
+ const active = activeSwitch();
102
+ if (!active || !sessionID || !targetModeID)
24
103
  return false;
104
+ if (active.options.accepts && !active.options.accepts({ ...input, sessionID, targetModeID }))
105
+ return false;
106
+ const hostAgent = input.hostAgent?.trim();
25
107
  pending.set(sessionID, {
26
108
  sessionID,
27
109
  targetModeID,
28
110
  ...(input.cursorSessionID ? { cursorSessionID: input.cursorSessionID } : {}),
111
+ ...(hostAgent ? { hostAgent } : {}),
29
112
  attempts: 0,
30
113
  });
31
114
  while (pending.size > MAX_PENDING_HOST_AGENT_SWITCHES) {
@@ -38,6 +121,17 @@ export function queueHostAgentModeSwitch(input) {
38
121
  `cursorSessionID=${input.cursorSessionID ?? ""} target=${targetModeID}`);
39
122
  return true;
40
123
  }
124
+ /**
125
+ * True while an approved switch into the host `plan` agent waits for its Run to
126
+ * end and will itself start the plan-agent turn that continues the work.
127
+ */
128
+ export function isHostPlanEntryPending(sessionID) {
129
+ if (activeSwitch()?.options.resumesTurn !== true)
130
+ return false;
131
+ const key = sessionID?.trim();
132
+ const target = key ? pending.get(key)?.targetModeID : undefined;
133
+ return target !== undefined && isPlanModeID(target);
134
+ }
41
135
  export function cancelHostAgentModeSwitch(sessionID) {
42
136
  const key = sessionID?.trim();
43
137
  if (key)
@@ -53,7 +147,8 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
53
147
  if (!key)
54
148
  return false;
55
149
  const state = pending.get(key);
56
- if (!state || !switchHostAgent)
150
+ const apply = activeSwitch()?.fn;
151
+ if (!state || !apply || applying.has(key))
57
152
  return false;
58
153
  if (options.terminal !== true || options.pumpActive || (options.pendingExecs ?? 0) > 0) {
59
154
  return false;
@@ -70,9 +165,12 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
70
165
  return false;
71
166
  }
72
167
  state.attempts += 1;
168
+ applying.set(key, state);
73
169
  try {
74
- await switchHostAgent(state);
75
- pending.delete(key);
170
+ await apply(state);
171
+ // A request queued during the callback belongs to a later transition.
172
+ if (pending.get(key) === state)
173
+ pending.delete(key);
76
174
  trace(`host-agent-mode: switched sessionID=${key} target=${state.targetModeID}`);
77
175
  return true;
78
176
  }
@@ -80,13 +178,19 @@ export async function flushHostAgentModeSwitch(sessionID, options = {}) {
80
178
  // Keep the request pending so a later explicit provider turn can retry at
81
179
  // its own terminal boundary. The provider-owned mode reminder remains the
82
180
  // behavioral fallback until the native host switch succeeds.
83
- delete state.cursorSessionID;
181
+ if (pending.get(key) === state)
182
+ delete state.cursorSessionID;
84
183
  trace(`host-agent-mode: FAILED sessionID=${key} target=${state.targetModeID} ` +
85
184
  `attempts=${state.attempts} err=${errorMessage(error)}`);
86
185
  return false;
87
186
  }
187
+ finally {
188
+ if (applying.get(key) === state)
189
+ applying.delete(key);
190
+ }
88
191
  }
89
192
  export function resetHostAgentModeSwitchForTests() {
90
- switchHostAgent = undefined;
193
+ registrations.length = 0;
91
194
  pending.clear();
195
+ applying.clear();
92
196
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `openCodeSessionId` undefined: every session (the host is shutting down).
3
+ * `at`: when the host stopped, on this process's clock.
4
+ */
5
+ export type HostInterruptListener = (openCodeSessionId: string | undefined, reason: string, at: number) => void | Promise<void>;
6
+ export declare function onHostInterrupt(listener: HostInterruptListener): () => void;
7
+ /**
8
+ * The host stopped this session's execution (OpenCode 2 never aborts `doStream`
9
+ * for it). `reason: "shutdown"` keeps the turn for the next server start: its
10
+ * state is saved, nothing is cancelled. Resolves once every listener settled.
11
+ */
12
+ export declare function notifyHostInterrupt(openCodeSessionId: string | undefined, reason: string, at?: number): Promise<void>;
@@ -0,0 +1,18 @@
1
+ // OpenCode 2 can evaluate the plugin and the AI SDK model as separate copies
2
+ // of this module (one per Location); the copy holding the Run must hear the
3
+ // plugin's interrupt.
4
+ const LISTENERS = Symbol.for("cursor-opencode-provider.host-interrupt-listeners");
5
+ const globals = globalThis;
6
+ const listeners = globals[LISTENERS] ??= new Set();
7
+ export function onHostInterrupt(listener) {
8
+ listeners.add(listener);
9
+ return () => { listeners.delete(listener); };
10
+ }
11
+ /**
12
+ * The host stopped this session's execution (OpenCode 2 never aborts `doStream`
13
+ * for it). `reason: "shutdown"` keeps the turn for the next server start: its
14
+ * state is saved, nothing is cancelled. Resolves once every listener settled.
15
+ */
16
+ export async function notifyHostInterrupt(openCodeSessionId, reason, at = Date.now()) {
17
+ await Promise.allSettled([...listeners].map(async (listener) => listener(openCodeSessionId, reason, at)));
18
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * OpenCode's own plan file per session, resolved while the session runs under
3
+ * the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
4
+ * reads exactly the file the user approves.
5
+ *
6
+ * Resolution is structural: session `slug` / `time.created` from the classic
7
+ * client, project VCS and worktree from the plugin input, and the location
8
+ * from OpenCode's `Session.plan` rule (or an injected host path bridge).
9
+ */
10
+ export declare function hostPlanFileFor(sessionID: string | undefined): string | undefined;
11
+ /** True once the session's plan file is known, including known to be absent. */
12
+ export declare function isHostPlanFileResolved(sessionID: string | undefined): boolean;
13
+ /**
14
+ * Record a session's plan file. `null` records that the host defines none, so
15
+ * it is not looked up again; `undefined` forgets it, so the next request retries.
16
+ */
17
+ export declare function setHostPlanFile(sessionID: string, file: string | null | undefined): void;
18
+ export declare function resetHostPlanFilesForTests(): void;
19
+ /**
20
+ * Resolve and remember the host plan file for one session. A session without
21
+ * one is remembered as such; a failed lookup is not, so a later request retries.
22
+ */
23
+ export declare function resolveHostPlanFile(input: {
24
+ sessionID: string;
25
+ getSession: (sessionID: string) => Promise<unknown>;
26
+ worktree: string | undefined;
27
+ vcs: boolean;
28
+ }): Promise<string | undefined>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * OpenCode's own plan file per session, resolved while the session runs under
3
+ * the `plan` agent. CreatePlan records the plan there so the `plan_exit` review
4
+ * reads exactly the file the user approves.
5
+ *
6
+ * Resolution is structural: session `slug` / `time.created` from the classic
7
+ * client, project VCS and worktree from the plugin input, and the location
8
+ * from OpenCode's `Session.plan` rule (or an injected host path bridge).
9
+ */
10
+ import { hostPlanFilePath } from "./context/paths.js";
11
+ import { errorMessage, trace } from "./debug.js";
12
+ /** Session → its plan file, or `null` when the host defines none for it. */
13
+ const hostPlanFiles = new Map();
14
+ const MAX_HOST_PLAN_FILES = 256;
15
+ export function hostPlanFileFor(sessionID) {
16
+ const key = sessionID?.trim();
17
+ return (key ? hostPlanFiles.get(key) : undefined) ?? undefined;
18
+ }
19
+ /** True once the session's plan file is known, including known to be absent. */
20
+ export function isHostPlanFileResolved(sessionID) {
21
+ const key = sessionID?.trim();
22
+ return !!key && hostPlanFiles.has(key);
23
+ }
24
+ /**
25
+ * Record a session's plan file. `null` records that the host defines none, so
26
+ * it is not looked up again; `undefined` forgets it, so the next request retries.
27
+ */
28
+ export function setHostPlanFile(sessionID, file) {
29
+ const key = sessionID.trim();
30
+ if (!key)
31
+ return;
32
+ hostPlanFiles.delete(key);
33
+ if (file === undefined || file === "")
34
+ return;
35
+ hostPlanFiles.set(key, file);
36
+ while (hostPlanFiles.size > MAX_HOST_PLAN_FILES) {
37
+ const oldest = hostPlanFiles.keys().next().value;
38
+ if (!oldest)
39
+ break;
40
+ hostPlanFiles.delete(oldest);
41
+ }
42
+ }
43
+ export function resetHostPlanFilesForTests() {
44
+ hostPlanFiles.clear();
45
+ }
46
+ function record(value) {
47
+ return value && typeof value === "object" && !Array.isArray(value)
48
+ ? value
49
+ : undefined;
50
+ }
51
+ /**
52
+ * Resolve and remember the host plan file for one session. A session without
53
+ * one is remembered as such; a failed lookup is not, so a later request retries.
54
+ */
55
+ export async function resolveHostPlanFile(input) {
56
+ try {
57
+ const response = record(await input.getSession(input.sessionID));
58
+ const info = record(response?.data) ?? response;
59
+ const slug = typeof info?.slug === "string" ? info.slug : "";
60
+ const created = record(info?.time)?.created;
61
+ if (!slug || typeof created !== "number" || !input.worktree) {
62
+ setHostPlanFile(input.sessionID, null);
63
+ return undefined;
64
+ }
65
+ const file = hostPlanFilePath({ worktree: input.worktree, vcs: input.vcs, created, slug });
66
+ setHostPlanFile(input.sessionID, file ?? null);
67
+ if (file)
68
+ trace(`host-plan-file: sessionID=${input.sessionID} file=${file}`);
69
+ return file;
70
+ }
71
+ catch (error) {
72
+ trace(`host-plan-file: unresolved sessionID=${input.sessionID} err=${errorMessage(error)}`);
73
+ setHostPlanFile(input.sessionID, undefined);
74
+ return undefined;
75
+ }
76
+ }