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.
- package/CHANGELOG.md +95 -0
- package/README.md +204 -37
- package/SECURITY.md +20 -8
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +1 -0
- package/dist/context/build.js +32 -5
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/paths.d.ts +1 -1
- package/dist/context/paths.js +7 -4
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.js +4 -3
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +114 -23
- package/dist/language-model.js +2290 -469
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +50 -29
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/plugin-opencode2.js +185 -25
- package/dist/plugin.js +28 -5
- package/dist/pricing-data.d.ts +26 -2
- package/dist/pricing-data.js +26 -2
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/messages.js +91 -8
- package/dist/protocol/request.d.ts +35 -19
- package/dist/protocol/request.js +53 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/tool-call-bridge.d.ts +2 -0
- package/dist/protocol/tool-call-bridge.js +10 -0
- package/dist/protocol/tools.d.ts +74 -8
- package/dist/protocol/tools.js +431 -141
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +16 -4
- package/dist/replay-safety.js +34 -25
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +94 -9
- package/dist/session.js +153 -34
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/transport/connect.d.ts +8 -1
- package/dist/transport/connect.js +103 -23
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +63 -29
- package/package.json +3 -2
- package/dist/protocol/thinking.d.ts +0 -15
- 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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/image-input.js
CHANGED
|
@@ -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
|
|
380
|
-
seenHashes.add(
|
|
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: [...
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|