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.
- package/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- 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 +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- 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-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- 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/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- 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/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- 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 +129 -10
- package/dist/session.js +198 -35
- 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/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- 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,
|
|
@@ -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
|
-
*
|
|
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
|
|
18
|
-
/**
|
|
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
|
package/dist/host-agent-mode.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|