cursor-opencode-provider 0.8.1 → 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 (96) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +204 -37
  3. package/SECURITY.md +20 -8
  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 +1 -0
  14. package/dist/context/build.js +32 -5
  15. package/dist/context/env.js +27 -3
  16. package/dist/context/epoch.js +9 -5
  17. package/dist/context/git.d.ts +5 -2
  18. package/dist/context/git.js +17 -7
  19. package/dist/context/paths.d.ts +1 -1
  20. package/dist/context/paths.js +7 -4
  21. package/dist/debug.js +13 -2
  22. package/dist/early-tool-rows.d.ts +60 -0
  23. package/dist/early-tool-rows.js +96 -0
  24. package/dist/errors.d.ts +49 -7
  25. package/dist/errors.js +163 -8
  26. package/dist/host-interrupt.d.ts +12 -0
  27. package/dist/host-interrupt.js +18 -0
  28. package/dist/host-steer.d.ts +71 -0
  29. package/dist/host-steer.js +232 -0
  30. package/dist/image-input.js +4 -3
  31. package/dist/index.d.ts +2 -2
  32. package/dist/language-model.d.ts +114 -23
  33. package/dist/language-model.js +2290 -469
  34. package/dist/lost-request.d.ts +39 -0
  35. package/dist/lost-request.js +111 -0
  36. package/dist/model-config.d.ts +3 -1
  37. package/dist/model-config.js +50 -29
  38. package/dist/models.d.ts +26 -3
  39. package/dist/models.js +101 -45
  40. package/dist/opencode2/tool-results.d.ts +18 -0
  41. package/dist/opencode2/tool-results.js +66 -0
  42. package/dist/plugin-opencode2.js +185 -25
  43. package/dist/plugin.js +28 -5
  44. package/dist/pricing-data.d.ts +26 -2
  45. package/dist/pricing-data.js +26 -2
  46. package/dist/pricing.d.ts +1 -1
  47. package/dist/pricing.js +7 -4
  48. package/dist/process-shared.d.ts +14 -0
  49. package/dist/process-shared.js +18 -0
  50. package/dist/protocol/apply-patch.d.ts +25 -13
  51. package/dist/protocol/apply-patch.js +79 -30
  52. package/dist/protocol/blob-store.d.ts +5 -1
  53. package/dist/protocol/blob-store.js +10 -6
  54. package/dist/protocol/client-version.js +6 -4
  55. package/dist/protocol/conversation-cache-header.d.ts +23 -0
  56. package/dist/protocol/conversation-cache-header.js +149 -0
  57. package/dist/protocol/conversation-persistence.d.ts +27 -1
  58. package/dist/protocol/conversation-persistence.js +628 -126
  59. package/dist/protocol/conversation-state.d.ts +8 -1
  60. package/dist/protocol/conversation-state.js +28 -7
  61. package/dist/protocol/host-notes.d.ts +31 -0
  62. package/dist/protocol/host-notes.js +85 -0
  63. package/dist/protocol/messages.js +91 -8
  64. package/dist/protocol/request.d.ts +35 -19
  65. package/dist/protocol/request.js +53 -28
  66. package/dist/protocol/sent-history-images.d.ts +49 -0
  67. package/dist/protocol/sent-history-images.js +102 -0
  68. package/dist/protocol/tool-call-bridge.d.ts +2 -0
  69. package/dist/protocol/tool-call-bridge.js +10 -0
  70. package/dist/protocol/tools.d.ts +74 -8
  71. package/dist/protocol/tools.js +431 -141
  72. package/dist/protocol/turn-provenance.d.ts +19 -4
  73. package/dist/protocol/turn-provenance.js +77 -20
  74. package/dist/replay-safety.d.ts +16 -4
  75. package/dist/replay-safety.js +34 -25
  76. package/dist/retry-budget.d.ts +21 -0
  77. package/dist/retry-budget.js +94 -0
  78. package/dist/runtime-files.d.ts +23 -0
  79. package/dist/runtime-files.js +581 -0
  80. package/dist/session.d.ts +94 -9
  81. package/dist/session.js +153 -34
  82. package/dist/shared.d.ts +1 -1
  83. package/dist/shared.js +1 -1
  84. package/dist/shell-timeout.d.ts +54 -10
  85. package/dist/shell-timeout.js +362 -92
  86. package/dist/stream-write.d.ts +20 -0
  87. package/dist/stream-write.js +87 -0
  88. package/dist/terminal-runner.d.ts +15 -0
  89. package/dist/terminal-runner.js +528 -0
  90. package/dist/transport/connect.d.ts +8 -1
  91. package/dist/transport/connect.js +103 -23
  92. package/dist/usage.d.ts +37 -17
  93. package/dist/usage.js +63 -29
  94. package/package.json +3 -2
  95. package/dist/protocol/thinking.d.ts +0 -15
  96. 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,
@@ -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,71 @@
1
+ export type HostSteer = {
2
+ sessionID: string;
3
+ inboxID: string;
4
+ text: string;
5
+ };
6
+ export type EarlySteer = HostSteer & {
7
+ injectionId: string;
8
+ /** The Cursor Run (`CursorSession.sessionId`) it was injected into, or the Run resumed from its checkpoint. */
9
+ run: string;
10
+ delivered: boolean;
11
+ /** A checkpoint arrived after Cursor delivered it, so a Run resumed from that checkpoint holds it. */
12
+ checkpointed: boolean;
13
+ /** Its Run reached `turn_ended` after delivering it, so the model has answered it. */
14
+ answered: boolean;
15
+ /** OpenCode's own copy came after the step's tool results and was left out of the continuation. */
16
+ promoted?: boolean;
17
+ /** Prompt boundary at which the host supplied its copy; distinct later replies do not match it. */
18
+ hostTurn?: string;
19
+ };
20
+ /** undefined when another plugin copy already announced the steer. */
21
+ export declare function announceHostSteer(steer: HostSteer): boolean | undefined;
22
+ /** Remember a queued message, so a later switch to `steer` can announce it. */
23
+ export declare function rememberQueuedSteer(steer: HostSteer): void;
24
+ export declare function takeQueuedSteer(inboxID: string): HostSteer | undefined;
25
+ export declare function listenForHostSteers(sessionID: string, listener: (steer: HostSteer) => boolean): () => void;
26
+ /** false when there is no room to track it; OpenCode's own copy then runs as usual. */
27
+ export declare function recordEarlySteer(steer: EarlySteer): boolean;
28
+ export declare function isEarlySteerTracked(sessionID: string, injectionId: string): boolean;
29
+ /** An injection Cursor never accepted. A promoted one stays, to be sent at `turn_ended`. */
30
+ export declare function forgetUndeliveredEarlySteer(sessionID: string, injectionId: string): void;
31
+ /** The host withdrew the message, so no copy of it will come. */
32
+ export declare function forgetEarlySteerOfInbox(sessionID: string, inboxID: string): void;
33
+ /** true when the injection is an early steer. */
34
+ export declare function markEarlySteerDelivered(sessionID: string | undefined, injectionId: string | undefined): boolean;
35
+ export declare function markEarlySteersCheckpointed(sessionID: string | undefined, run: string): void;
36
+ /**
37
+ * At `turn_ended`: a delivered steer is answered and waits for OpenCode's copy; one Cursor never took is
38
+ * forgotten, so OpenCode's copy runs as an ordinary message. A promoted one has no copy left to come:
39
+ * one Cursor never took is returned for a follow-up Run and stays, as sent, until a checkpoint holds it.
40
+ * Answered receipts survive retries and are retired when a later Run finishes.
41
+ */
42
+ export declare function settleEarlySteers(sessionID: string | undefined, run: string): {
43
+ answered: number;
44
+ undelivered: EarlySteer[];
45
+ leftToHost: number;
46
+ };
47
+ /**
48
+ * A failed Run's steers: those its checkpoint holds go to the Run resumed from it (`to`), and so does a
49
+ * promoted one it does not hold, to be sent at that Run's `turn_ended`. The rest are forgotten, so
50
+ * OpenCode's own copy of each runs as an ordinary message. A rebase (`to` undefined) seeds from host
51
+ * history, which already holds a promoted copy.
52
+ */
53
+ export declare function handOverEarlySteers(sessionID: string | undefined, from: string, to: string | undefined): void;
54
+ /** The host may add `<system-reminder>` parts to a promoted copy; any other difference is another message. */
55
+ export declare function carriesSteer(message: string, text: string): boolean;
56
+ /** The record each message carries, or nothing unless every message carries one. */
57
+ export declare function matchEarlySteers(sessionID: string | undefined, messages: readonly string[], accept: (record: EarlySteer) => boolean): EarlySteer[];
58
+ export declare function forgetEarlySteerRecords(sessionID: string, records: readonly EarlySteer[]): void;
59
+ /**
60
+ * A steer of a Run that ended without answering it (a stop, a failed recovery) that only host history
61
+ * holds reliably: OpenCode placed it in its history (a checkpoint may hold it too, and resuming would
62
+ * then send it twice), or a checkpoint holds it while OpenCode's copy is still to come.
63
+ */
64
+ export declare function needsHostHistory(sessionID: string | undefined, openRun: string | undefined): boolean;
65
+ /** Keep answered receipts until the new turn succeeds, so a host retry still omits its copies. */
66
+ export declare function clearUnansweredEarlySteers(sessionID: string | undefined): void;
67
+ /** Private restart metadata, saved atomically with the checkpoint it describes. */
68
+ export declare function serializeEarlySteers(sessionID: string): string | undefined;
69
+ export declare function restoreEarlySteers(sessionID: string, encoded: string | undefined): void;
70
+ export declare function forgetEarlySteers(sessionID: string): void;
71
+ export declare function resetHostSteersForTests(): void;
@@ -0,0 +1,232 @@
1
+ import { processShared } from "./process-shared.js";
2
+ const MAX_RECORDS_PER_SESSION = 32;
3
+ const MAX_REMEMBERED = 256;
4
+ // The plugin and the model can be separate copies of this module, and each plugin copy sees every event.
5
+ const state = processShared("host-steers.v1", () => ({
6
+ listeners: new Map(),
7
+ injected: new Map(),
8
+ queued: new Map(),
9
+ announced: new Set(),
10
+ }));
11
+ function remember(entries, key) {
12
+ while (entries.size > MAX_REMEMBERED) {
13
+ const oldest = entries.keys().next().value;
14
+ if (oldest === undefined || oldest === key)
15
+ break;
16
+ entries.delete(oldest);
17
+ }
18
+ }
19
+ /** undefined when another plugin copy already announced the steer. */
20
+ export function announceHostSteer(steer) {
21
+ if (state.announced.has(steer.inboxID))
22
+ return undefined;
23
+ state.announced.add(steer.inboxID);
24
+ remember(state.announced, steer.inboxID);
25
+ const listener = state.listeners.get(steer.sessionID);
26
+ return listener ? listener(steer) : false;
27
+ }
28
+ /** Remember a queued message, so a later switch to `steer` can announce it. */
29
+ export function rememberQueuedSteer(steer) {
30
+ if (state.announced.has(steer.inboxID))
31
+ return;
32
+ state.queued.delete(steer.inboxID);
33
+ state.queued.set(steer.inboxID, steer);
34
+ remember(state.queued, steer.inboxID);
35
+ }
36
+ export function takeQueuedSteer(inboxID) {
37
+ const steer = state.queued.get(inboxID);
38
+ state.queued.delete(inboxID);
39
+ return steer;
40
+ }
41
+ export function listenForHostSteers(sessionID, listener) {
42
+ state.listeners.set(sessionID, listener);
43
+ return () => {
44
+ if (state.listeners.get(sessionID) === listener)
45
+ state.listeners.delete(sessionID);
46
+ };
47
+ }
48
+ /** false when there is no room to track it; OpenCode's own copy then runs as usual. */
49
+ export function recordEarlySteer(steer) {
50
+ const records = state.injected.get(steer.sessionID);
51
+ if (records ? records.length >= MAX_RECORDS_PER_SESSION : state.injected.size >= MAX_REMEMBERED)
52
+ return false;
53
+ state.injected.set(steer.sessionID, [...records ?? [], steer]);
54
+ return true;
55
+ }
56
+ function keep(sessionID, records) {
57
+ if (records.length === 0)
58
+ state.injected.delete(sessionID);
59
+ else
60
+ state.injected.set(sessionID, records);
61
+ }
62
+ export function isEarlySteerTracked(sessionID, injectionId) {
63
+ return !!state.injected.get(sessionID)?.some((record) => record.injectionId === injectionId);
64
+ }
65
+ /** An injection Cursor never accepted. A promoted one stays, to be sent at `turn_ended`. */
66
+ export function forgetUndeliveredEarlySteer(sessionID, injectionId) {
67
+ keep(sessionID, (state.injected.get(sessionID) ?? [])
68
+ .filter((record) => record.injectionId !== injectionId || record.delivered || record.promoted));
69
+ }
70
+ /** The host withdrew the message, so no copy of it will come. */
71
+ export function forgetEarlySteerOfInbox(sessionID, inboxID) {
72
+ keep(sessionID, (state.injected.get(sessionID) ?? []).filter((record) => record.inboxID !== inboxID));
73
+ }
74
+ /** true when the injection is an early steer. */
75
+ export function markEarlySteerDelivered(sessionID, injectionId) {
76
+ const record = sessionID && injectionId
77
+ ? state.injected.get(sessionID)?.find((candidate) => candidate.injectionId === injectionId)
78
+ : undefined;
79
+ if (record)
80
+ record.delivered = true;
81
+ return !!record;
82
+ }
83
+ export function markEarlySteersCheckpointed(sessionID, run) {
84
+ for (const record of (sessionID && state.injected.get(sessionID)) || []) {
85
+ if (record.run === run && record.delivered)
86
+ record.checkpointed = true;
87
+ }
88
+ }
89
+ /**
90
+ * At `turn_ended`: a delivered steer is answered and waits for OpenCode's copy; one Cursor never took is
91
+ * forgotten, so OpenCode's copy runs as an ordinary message. A promoted one has no copy left to come:
92
+ * one Cursor never took is returned for a follow-up Run and stays, as sent, until a checkpoint holds it.
93
+ * Answered receipts survive retries and are retired when a later Run finishes.
94
+ */
95
+ export function settleEarlySteers(sessionID, run) {
96
+ const records = sessionID ? state.injected.get(sessionID) : undefined;
97
+ if (!records)
98
+ return { answered: 0, undelivered: [], leftToHost: 0 };
99
+ let answered = 0;
100
+ let leftToHost = 0;
101
+ const undelivered = [];
102
+ const kept = records.filter((record) => {
103
+ if (record.run !== run)
104
+ return !record.answered;
105
+ if (record.promoted) {
106
+ if (record.delivered) {
107
+ if (!record.answered) {
108
+ record.answered = true;
109
+ answered++;
110
+ }
111
+ return true;
112
+ }
113
+ undelivered.push(record);
114
+ record.delivered = true;
115
+ return true;
116
+ }
117
+ if (!record.delivered) {
118
+ leftToHost++;
119
+ return false;
120
+ }
121
+ if (!record.answered) {
122
+ record.answered = true;
123
+ answered++;
124
+ }
125
+ return true;
126
+ });
127
+ keep(sessionID, kept);
128
+ return { answered, undelivered, leftToHost };
129
+ }
130
+ /**
131
+ * A failed Run's steers: those its checkpoint holds go to the Run resumed from it (`to`), and so does a
132
+ * promoted one it does not hold, to be sent at that Run's `turn_ended`. The rest are forgotten, so
133
+ * OpenCode's own copy of each runs as an ordinary message. A rebase (`to` undefined) seeds from host
134
+ * history, which already holds a promoted copy.
135
+ */
136
+ export function handOverEarlySteers(sessionID, from, to) {
137
+ const records = sessionID ? state.injected.get(sessionID) : undefined;
138
+ if (!records)
139
+ return;
140
+ keep(sessionID, records.filter((record) => {
141
+ if (record.run !== from || record.answered)
142
+ return true;
143
+ if (!to || (!record.checkpointed && !record.promoted))
144
+ return false;
145
+ record.run = to;
146
+ if (!record.checkpointed)
147
+ record.delivered = false;
148
+ return true;
149
+ }));
150
+ }
151
+ const SYSTEM_REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
152
+ /** The host may add `<system-reminder>` parts to a promoted copy; any other difference is another message. */
153
+ export function carriesSteer(message, text) {
154
+ return message.replace(SYSTEM_REMINDER, "").trim() === text.trim();
155
+ }
156
+ /** The record each message carries, or nothing unless every message carries one. */
157
+ export function matchEarlySteers(sessionID, messages, accept) {
158
+ const records = sessionID ? state.injected.get(sessionID) : undefined;
159
+ if (!records?.length || messages.length === 0)
160
+ return [];
161
+ const candidates = [...records];
162
+ const matched = [];
163
+ for (const message of messages) {
164
+ const index = candidates.findIndex((record) => accept(record) && carriesSteer(message, record.text));
165
+ if (index === -1)
166
+ return [];
167
+ matched.push(...candidates.splice(index, 1));
168
+ }
169
+ return matched;
170
+ }
171
+ export function forgetEarlySteerRecords(sessionID, records) {
172
+ keep(sessionID, (state.injected.get(sessionID) ?? []).filter((record) => !records.includes(record)));
173
+ }
174
+ /**
175
+ * A steer of a Run that ended without answering it (a stop, a failed recovery) that only host history
176
+ * holds reliably: OpenCode placed it in its history (a checkpoint may hold it too, and resuming would
177
+ * then send it twice), or a checkpoint holds it while OpenCode's copy is still to come.
178
+ */
179
+ export function needsHostHistory(sessionID, openRun) {
180
+ return !!sessionID && !!state.injected.get(sessionID)?.some((record) => !record.answered && record.run !== openRun && (record.promoted || record.checkpointed));
181
+ }
182
+ /** Keep answered receipts until the new turn succeeds, so a host retry still omits its copies. */
183
+ export function clearUnansweredEarlySteers(sessionID) {
184
+ if (sessionID)
185
+ keep(sessionID, (state.injected.get(sessionID) ?? []).filter((record) => record.answered));
186
+ }
187
+ /** Private restart metadata, saved atomically with the checkpoint it describes. */
188
+ export function serializeEarlySteers(sessionID) {
189
+ const records = state.injected.get(sessionID);
190
+ return records?.length ? JSON.stringify({ version: 1, records }) : undefined;
191
+ }
192
+ export function restoreEarlySteers(sessionID, encoded) {
193
+ if (!encoded || state.injected.has(sessionID) || state.injected.size >= MAX_REMEMBERED)
194
+ return;
195
+ let value;
196
+ try {
197
+ value = JSON.parse(encoded);
198
+ }
199
+ catch {
200
+ return;
201
+ }
202
+ if (!value || value.version !== 1 || !Array.isArray(value.records) || value.records.length > MAX_RECORDS_PER_SESSION)
203
+ return;
204
+ const records = [];
205
+ for (const record of value.records) {
206
+ if (!record || record.sessionID !== sessionID
207
+ || ![record.inboxID, record.injectionId, record.run, record.text].every((part) => typeof part === "string" && part.length > 0)
208
+ || ![record.delivered, record.checkpointed, record.answered].every((part) => typeof part === "boolean")
209
+ || ((record.checkpointed || record.answered) && !record.delivered)
210
+ || (record.promoted !== undefined && typeof record.promoted !== "boolean")
211
+ || (record.hostTurn !== undefined && (typeof record.hostTurn !== "string" || !/^[a-f0-9]{64}$/.test(record.hostTurn))))
212
+ return;
213
+ records.push({
214
+ sessionID, inboxID: record.inboxID, injectionId: record.injectionId, run: record.run, text: record.text,
215
+ delivered: record.delivered, checkpointed: record.checkpointed, answered: record.answered,
216
+ ...(record.promoted !== undefined ? { promoted: record.promoted } : {}),
217
+ ...(record.hostTurn !== undefined ? { hostTurn: record.hostTurn } : {}),
218
+ });
219
+ }
220
+ if (records.length)
221
+ state.injected.set(sessionID, records);
222
+ }
223
+ export function forgetEarlySteers(sessionID) {
224
+ state.injected.delete(sessionID);
225
+ state.listeners.delete(sessionID);
226
+ }
227
+ export function resetHostSteersForTests() {
228
+ state.listeners.clear();
229
+ state.injected.clear();
230
+ state.queued.clear();
231
+ state.announced.clear();
232
+ }
@@ -374,10 +374,11 @@ export async function extractCursorPromptImages(prompt, lastUser, options) {
374
374
  return true;
375
375
  }) : await extractCursorUserImages(lastUser, options.signal, maxBytes);
376
376
  const userBytes = userImages.reduce((total, image) => total + image.data.length, 0);
377
+ const userHashes = toolCaption ? captionHashes : userImages.map((image) => imageContentHash(image.data));
377
378
  // Seed history dedupe with this-turn last-user hashes so the same bytes on an
378
379
  // earlier user/assistant/tool message are not attached twice in one Run.
379
- for (const image of userImages)
380
- seenHashes.add(imageContentHash(image.data));
380
+ for (const hash of userHashes)
381
+ seenHashes.add(hash);
381
382
  const history = await extractCursorHistoryImages(prompt, {
382
383
  supportsImages: options.supportsImages,
383
384
  seenHashes,
@@ -387,7 +388,7 @@ export async function extractCursorPromptImages(prompt, lastUser, options) {
387
388
  });
388
389
  return {
389
390
  ...history,
390
- hashes: [...captionHashes, ...history.hashes],
391
+ hashes: [...userHashes, ...history.hashes],
391
392
  duplicateCount: history.duplicateCount + captionDuplicates,
392
393
  images: [...userImages, ...history.images],
393
394
  userImageCount: toolCaption ? 0 : userImages.length,
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ import type { CursorContinuationOptions } from "./session.js";
3
3
  import type { HostToolDialect } from "./protocol/tools.js";
4
4
  import type { AccessTokenProvider } from "./auth-renewal.js";
5
5
  export type CursorRetryOptions = {
6
- /** Total attempts including the initial request. Default: 3. */
6
+ /** Optional total attempt ceiling (including the initial request), across host retries. */
7
7
  maxAttempts?: number;
8
8
  /** Initial full-jitter backoff ceiling. Default: 500ms. */
9
9
  baseDelayMs?: number;
@@ -42,7 +42,7 @@ export type CreateCursorOptions = {
42
42
  cacheDir?: string;
43
43
  /** Held-stream policy. Defaults: heartbeat 5s, semantic idle 120s, tool inactivity 10m. */
44
44
  continuation?: CursorContinuationOptions;
45
- /** Fresh-turn retry policy. Defaults: 3 attempts, 500ms base, 8000ms cap. */
45
+ /** Run retry policy. Defaults: 10 transport / 3 server retries, 500ms base, 8000ms delay cap. */
46
46
  retry?: CursorRetryOptions;
47
47
  /** Fallback host dialect when schemas are omitted or ambiguous. Default: OpenCode 1.x. */
48
48
  defaultDialect?: HostToolDialect;