cursor-opencode-provider 0.2.9 → 0.3.0
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/README.md +128 -8
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +9 -0
- package/dist/compaction-marker.d.ts +14 -0
- package/dist/compaction-marker.js +36 -0
- package/dist/context/env.js +6 -2
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +37 -33
- package/dist/model-config.d.ts +15 -0
- package/dist/model-config.js +188 -0
- package/dist/opencode2/catalog.d.ts +33 -0
- package/dist/opencode2/catalog.js +85 -0
- package/dist/opencode2/integration.d.ts +23 -0
- package/dist/opencode2/integration.js +124 -0
- package/dist/opencode2/types.d.ts +316 -0
- package/dist/opencode2/types.js +18 -0
- package/dist/plugin-core.d.ts +27 -0
- package/dist/plugin-core.js +31 -0
- package/dist/plugin-opencode2.d.ts +3 -0
- package/dist/plugin-opencode2.js +293 -0
- package/dist/plugin-v2.d.ts +7 -0
- package/dist/plugin-v2.js +1 -15
- package/dist/plugin.d.ts +1 -15
- package/dist/plugin.js +11 -191
- package/dist/protocol/messages.d.ts +10 -0
- package/dist/protocol/messages.js +56 -0
- package/dist/protocol/tools.d.ts +8 -1
- package/dist/protocol/tools.js +9 -4
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +13 -1
- package/dist/session.js +70 -1
- package/dist/shell-timeout.d.ts +3 -1
- package/dist/shell-timeout.js +6 -2
- package/dist/usage.d.ts +30 -0
- package/dist/usage.js +77 -0
- package/dist/web-search-tool.d.ts +33 -0
- package/dist/web-search-tool.js +22 -0
- package/dist/web-tools.d.ts +9 -25
- package/dist/web-tools.js +34 -36
- package/package.json +8 -3
package/dist/plugin-v2.d.ts
CHANGED
|
@@ -1,2 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Effect / Promise v2 plugin.
|
|
3
|
+
*
|
|
4
|
+
* Wires the Cursor provider through `ctx.aisdk.sdk` / `ctx.aisdk.language`
|
|
5
|
+
* (the V2 extension points). Auth still lives in the classic Hooks plugin
|
|
6
|
+
* (`plugin.ts`) until OpenCode integrations fully replace provider OAuth.
|
|
7
|
+
*/
|
|
1
8
|
declare const _default: import("@opencode-ai/plugin/v2/promise").Plugin;
|
|
2
9
|
export default _default;
|
package/dist/plugin-v2.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { define } from "@opencode-ai/plugin/v2/promise";
|
|
2
2
|
import { CURSOR_PROVIDER_ID } from "./shared.js";
|
|
3
|
-
import {
|
|
3
|
+
import { createSdk, isCursorPackage } from "./plugin-core.js";
|
|
4
4
|
import { adoptCompatHostCacheDir } from "./context/paths.js";
|
|
5
5
|
/**
|
|
6
6
|
* OpenCode Effect / Promise v2 plugin.
|
|
@@ -9,20 +9,6 @@ import { adoptCompatHostCacheDir } from "./context/paths.js";
|
|
|
9
9
|
* (the V2 extension points). Auth still lives in the classic Hooks plugin
|
|
10
10
|
* (`plugin.ts`) until OpenCode integrations fully replace provider OAuth.
|
|
11
11
|
*/
|
|
12
|
-
function isCursorPackage(pkg, providerID) {
|
|
13
|
-
if (providerID === CURSOR_PROVIDER_ID)
|
|
14
|
-
return true;
|
|
15
|
-
return (pkg.includes("cursor-opencode-provider") ||
|
|
16
|
-
/cursor-opencode-provider[/\\]dist[/\\]index\.js/.test(pkg));
|
|
17
|
-
}
|
|
18
|
-
function createSdk(options) {
|
|
19
|
-
const providerId = options.name || CURSOR_PROVIDER_ID;
|
|
20
|
-
return {
|
|
21
|
-
languageModel(modelId) {
|
|
22
|
-
return createCursorLanguageModel(modelId, providerId, options);
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
12
|
export default define({
|
|
27
13
|
id: "cursor.provider",
|
|
28
14
|
setup: async (ctx) => {
|
package/dist/plugin.d.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
1
|
import type { Hooks, PluginInput } from "@opencode-ai/plugin";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
5
|
-
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
6
|
-
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
7
|
-
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
8
|
-
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
9
|
-
* tagged redundantly.
|
|
10
|
-
*/
|
|
11
|
-
export declare function thinkingSuffixBaseNames(models: ModelInfo[]): Set<string>;
|
|
12
|
-
export declare function modelInfoToConfig(mi: ModelInfo, options?: {
|
|
13
|
-
thinkingSuffix?: boolean;
|
|
14
|
-
contextTier?: "base" | "long";
|
|
15
|
-
}): Record<string, any>;
|
|
16
|
-
export declare function modelsToConfig(models: ModelInfo[]): Record<string, any>;
|
|
2
|
+
export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
|
|
17
3
|
export declare function CursorPlugin(input: PluginInput): Promise<Hooks>;
|
package/dist/plugin.js
CHANGED
|
@@ -1,199 +1,19 @@
|
|
|
1
1
|
import { CURSOR_COMPACTION_OPTION, CURSOR_PROVIDER_ID, CURSOR_WEBSITE_HOST, CURSOR_API_HOST } from "./shared.js";
|
|
2
|
+
import { cursorApiBaseURL, cursorGetServerConfigTelemetryEnabled } from "./plugin-core.js";
|
|
2
3
|
import { pollForTokens, exchangeApiKey, refreshAccessToken, isExpiringSoon, generatePkceParams, generatePkceChallenge, buildLoginUrl, decodeJwtExpiryMs } from "./auth.js";
|
|
3
|
-
import {
|
|
4
|
+
import { readCache, discoverModels, isCacheFresh } from "./models.js";
|
|
5
|
+
// Re-exported for API compatibility: tests and downstream code import these
|
|
6
|
+
// from "./plugin.js". Canonical home is ./model-config.ts, which stays free of
|
|
7
|
+
// host plugin imports so the OpenCode 2.0 entrypoint can share it.
|
|
8
|
+
import { modelsToConfig } from "./model-config.js";
|
|
9
|
+
export { modelInfoToConfig, modelsToConfig, thinkingSuffixBaseNames } from "./model-config.js";
|
|
4
10
|
import { adoptCompatHostCacheDir, opencodeGlobalCacheDir } from "./context/paths.js";
|
|
5
11
|
import { readStoredAuth } from "./context/auth-store.js";
|
|
6
12
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
7
13
|
import { captureCursorShellResult, cursorShellEnvForCall, cursorShellOriginalCommand, prepareCursorShellArgs, releaseCursorShellEnv, sanitizeRegisteredCursorShellOutput, setCursorShellPath, } from "./shell-timeout.js";
|
|
8
14
|
import { sessionActivity } from "./activity.js";
|
|
9
|
-
import { openCodeWebSearchTool } from "./web-
|
|
15
|
+
import { openCodeWebSearchTool } from "./web-search-tool.js";
|
|
10
16
|
const MODULE_URL = new URL("./index.js", import.meta.url).href;
|
|
11
|
-
/**
|
|
12
|
-
* Strip characters and markup that break rendering in the OpenCode TUI/GUI from
|
|
13
|
-
* a model or variant display name:
|
|
14
|
-
* • HTML tags (e.g. `<span style="…">Medium</span>`) — the IDE colours variant
|
|
15
|
-
* suffixes with a CSS var that doesn't exist in OpenCode, so the raw markup
|
|
16
|
-
* would show as literal text. We drop the tags and keep the inner text
|
|
17
|
-
* ("Medium").
|
|
18
|
-
* • HTML/markup chars (`< > & " ' \``), parentheses.
|
|
19
|
-
* • Tabs/newlines collapse to single spaces.
|
|
20
|
-
* Dots and unicode letters are preserved so names stay readable.
|
|
21
|
-
* Fixes https://github.com/oakimov/cursor-opencode-provider/issues/2.
|
|
22
|
-
*/
|
|
23
|
-
function safeLabel(value) {
|
|
24
|
-
return (value
|
|
25
|
-
.replace(/<[^>]+>/g, "")
|
|
26
|
-
.replace(/[()<>&"'`]/g, "")
|
|
27
|
-
.replace(/\s+/g, " ")
|
|
28
|
-
.trim() || "default");
|
|
29
|
-
}
|
|
30
|
-
function baseName(mi) {
|
|
31
|
-
return safeLabel(mi.displayName ?? mi.id);
|
|
32
|
-
}
|
|
33
|
-
function modelInfoVariants(mi, variants) {
|
|
34
|
-
if (variants.length === 0)
|
|
35
|
-
return undefined;
|
|
36
|
-
const entries = {};
|
|
37
|
-
const usedKeys = new Set();
|
|
38
|
-
const baseName = safeLabel(mi.displayName ?? mi.id);
|
|
39
|
-
// Each variant's key is `safeLabel(displayName)` (the IDE's own label, e.g.
|
|
40
|
-
// "Opus 4.8 1M High Fast Thinking") so the picker matches what the user
|
|
41
|
-
// sees in Cursor. Two variants can sanitize to the same name when the IDE
|
|
42
|
-
// wraps a differentiator in `<span>…</span>` (e.g. Composer's "Fast"
|
|
43
|
-
// suffix collapses to the bare model name after stripping). To guarantee
|
|
44
|
-
// every variant stays pickable:
|
|
45
|
-
// 1. If the sanitized displayName matches the model name itself, suffix
|
|
46
|
-
// it with distinguishing params (or "default") so it never collides
|
|
47
|
-
// with the model entry in the variant panel.
|
|
48
|
-
// 2. If two variants still collide, tag the later one with its params.
|
|
49
|
-
// Suffixes must stay free of `()` / markup chars — same constraint as
|
|
50
|
-
// safeLabel (issue #2); use spaced tokens, not parenthetical tags.
|
|
51
|
-
const tagDims = (p) => {
|
|
52
|
-
const labels = [];
|
|
53
|
-
for (const d of p) {
|
|
54
|
-
if (d.id === "fast" && d.value === "true")
|
|
55
|
-
labels.push("Fast");
|
|
56
|
-
else if (d.id === "thinking" && d.value === "true")
|
|
57
|
-
labels.push("Thinking");
|
|
58
|
-
else if (d.id === "context")
|
|
59
|
-
labels.push(d.value);
|
|
60
|
-
}
|
|
61
|
-
if (labels.length > 0)
|
|
62
|
-
return ` ${labels.join(" ")}`;
|
|
63
|
-
// No params at all — still disambiguate from the model name itself.
|
|
64
|
-
if (p.length === 0)
|
|
65
|
-
return "";
|
|
66
|
-
return " default";
|
|
67
|
-
};
|
|
68
|
-
for (const v of variants) {
|
|
69
|
-
const sanitized = safeLabel(v.displayName || v.key || "default");
|
|
70
|
-
let key = sanitized;
|
|
71
|
-
// Never let a variant key equal the model name — that would make the
|
|
72
|
-
// variant entry indistinguishable from the model entry in pickers that
|
|
73
|
-
// collapse them.
|
|
74
|
-
if (key === baseName && !usedKeys.has(key)) {
|
|
75
|
-
key = `${baseName}${tagDims(v.parameterValues)}` || `${baseName} default`;
|
|
76
|
-
}
|
|
77
|
-
else if (usedKeys.has(key)) {
|
|
78
|
-
key = `${sanitized}${tagDims(v.parameterValues)}`;
|
|
79
|
-
}
|
|
80
|
-
let n = 2;
|
|
81
|
-
while (usedKeys.has(key))
|
|
82
|
-
key = `${sanitized}${tagDims(v.parameterValues)} ${n++}`;
|
|
83
|
-
usedKeys.add(key);
|
|
84
|
-
entries[key] = {
|
|
85
|
-
[CURSOR_VARIANT_PARAMETERS_KEY]: v.parameterValues.map((p) => ({ ...p })),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
return entries;
|
|
89
|
-
}
|
|
90
|
-
function isLongContextVariant(v) {
|
|
91
|
-
return v.parameterValues.some((p) => p.id === "context" && parseCursorContextLimit(p.value) === 1_000_000);
|
|
92
|
-
}
|
|
93
|
-
function variantsForTier(mi, tier) {
|
|
94
|
-
return mi.variants.filter((v) => isLongContextVariant(v) === (tier === "long"));
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Display names shared by both a thinking and a non-thinking model. A thinking
|
|
98
|
-
* model with such a name needs a "Thinking" tag to disambiguate it from its
|
|
99
|
-
* non-thinking twin (Cursor's Claude/Fable/Sonnet families). Models whose names
|
|
100
|
-
* are already unique — including Cursor's GPT family, where the reasoning tier
|
|
101
|
-
* ("None"/"Low"/"High"…) is baked into the name — are excluded, so they aren't
|
|
102
|
-
* tagged redundantly.
|
|
103
|
-
*/
|
|
104
|
-
export function thinkingSuffixBaseNames(models) {
|
|
105
|
-
const flags = new Map();
|
|
106
|
-
for (const m of models) {
|
|
107
|
-
const base = baseName(m);
|
|
108
|
-
const entry = flags.get(base) ?? { hasThinking: false, hasNonThinking: false };
|
|
109
|
-
if (m.supportsThinking)
|
|
110
|
-
entry.hasThinking = true;
|
|
111
|
-
else
|
|
112
|
-
entry.hasNonThinking = true;
|
|
113
|
-
flags.set(base, entry);
|
|
114
|
-
}
|
|
115
|
-
const ambiguous = new Set();
|
|
116
|
-
for (const [base, f] of flags)
|
|
117
|
-
if (f.hasThinking && f.hasNonThinking)
|
|
118
|
-
ambiguous.add(base);
|
|
119
|
-
return ambiguous;
|
|
120
|
-
}
|
|
121
|
-
export function modelInfoToConfig(mi, options = {}) {
|
|
122
|
-
const contextTier = options.contextTier ?? "base";
|
|
123
|
-
const variants = variantsForTier(mi, contextTier);
|
|
124
|
-
let name = baseName(mi);
|
|
125
|
-
if (options.thinkingSuffix)
|
|
126
|
-
name += " Thinking";
|
|
127
|
-
if (contextTier === "long")
|
|
128
|
-
name += " 1M";
|
|
129
|
-
// OpenCode's context limit is static per model entry, while Cursor's context
|
|
130
|
-
// tier is a variant parameter. Long-context choices are therefore emitted as
|
|
131
|
-
// separate OpenCode entries by modelsToConfig.
|
|
132
|
-
const context = contextTier === "long"
|
|
133
|
-
? (mi.maxContextForMaxMode ?? 1_000_000)
|
|
134
|
-
: (mi.maxContext ?? 200_000);
|
|
135
|
-
// OpenCode's overflow/compaction/UI use limit.context; generation and
|
|
136
|
-
// thinking budgets use limit.output. models.dev 1M peers advertise
|
|
137
|
-
// 64k–128k output — a tiny cap makes long-context sessions feel broken
|
|
138
|
-
// even when the 1M input window is correct.
|
|
139
|
-
const output = contextTier === "long" ? 128_000 : 32_000;
|
|
140
|
-
const config = {
|
|
141
|
-
name,
|
|
142
|
-
reasoning: mi.supportsThinking ?? false,
|
|
143
|
-
tool_call: mi.supportsAgent ?? true,
|
|
144
|
-
temperature: false,
|
|
145
|
-
limit: {
|
|
146
|
-
context,
|
|
147
|
-
output,
|
|
148
|
-
},
|
|
149
|
-
};
|
|
150
|
-
const variantConfig = modelInfoVariants(mi, variants);
|
|
151
|
-
if (variantConfig)
|
|
152
|
-
config.variants = variantConfig;
|
|
153
|
-
if (contextTier === "long") {
|
|
154
|
-
const defaultVariant = variants.find((v) => v.isDefaultMax) ?? variants[0];
|
|
155
|
-
if (defaultVariant) {
|
|
156
|
-
config.options = {
|
|
157
|
-
[CURSOR_WIRE_MODEL_ID_KEY]: mi.id,
|
|
158
|
-
[CURSOR_VARIANT_PARAMETERS_KEY]: defaultVariant.parameterValues.map((p) => ({ ...p })),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return config;
|
|
163
|
-
}
|
|
164
|
-
export function modelsToConfig(models) {
|
|
165
|
-
const ambiguous = thinkingSuffixBaseNames(models);
|
|
166
|
-
const out = {};
|
|
167
|
-
const usedIds = new Set(models.map((m) => m.id));
|
|
168
|
-
for (const m of models) {
|
|
169
|
-
const thinkingSuffix = !!m.supportsThinking && ambiguous.has(baseName(m));
|
|
170
|
-
const baseVariants = variantsForTier(m, "base");
|
|
171
|
-
const longVariants = variantsForTier(m, "long");
|
|
172
|
-
if (baseVariants.length > 0 || longVariants.length === 0) {
|
|
173
|
-
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "base" });
|
|
174
|
-
}
|
|
175
|
-
if (longVariants.length === 0)
|
|
176
|
-
continue;
|
|
177
|
-
if (baseVariants.length === 0) {
|
|
178
|
-
out[m.id] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
let longId = `${m.id}-1m`;
|
|
182
|
-
let suffix = 2;
|
|
183
|
-
while (usedIds.has(longId))
|
|
184
|
-
longId = `${m.id}-1m-${suffix++}`;
|
|
185
|
-
usedIds.add(longId);
|
|
186
|
-
out[longId] = modelInfoToConfig(m, { thinkingSuffix, contextTier: "long" });
|
|
187
|
-
}
|
|
188
|
-
return out;
|
|
189
|
-
}
|
|
190
|
-
function cursorApiBaseURL() {
|
|
191
|
-
return process.env.CURSOR_API_BASE_URL ?? `https://${CURSOR_API_HOST}`;
|
|
192
|
-
}
|
|
193
|
-
function cursorGetServerConfigTelemetryEnabled() {
|
|
194
|
-
return (process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "1" ||
|
|
195
|
-
process.env.CURSOR_GET_SERVER_CONFIG_TELEMETRY === "true");
|
|
196
|
-
}
|
|
197
17
|
export async function CursorPlugin(input) {
|
|
198
18
|
// Prefer strong OCP host identity when available; otherwise resolve from explicit host signals/install path.
|
|
199
19
|
await adoptCompatHostCacheDir();
|
|
@@ -443,10 +263,10 @@ export async function CursorPlugin(input) {
|
|
|
443
263
|
type: "text",
|
|
444
264
|
key: "apiKey",
|
|
445
265
|
message: "Cursor API key",
|
|
446
|
-
placeholder: "
|
|
266
|
+
placeholder: "crsr_...",
|
|
447
267
|
validate(value) {
|
|
448
|
-
if (!value.startsWith("
|
|
449
|
-
return "API key should start with
|
|
268
|
+
if (!value.startsWith("crsr_"))
|
|
269
|
+
return "API key should start with crsr_";
|
|
450
270
|
return undefined;
|
|
451
271
|
},
|
|
452
272
|
},
|
|
@@ -3,6 +3,16 @@ export declare function createMessageTypes(): protobuf.Root;
|
|
|
3
3
|
export declare function getMessageTypes(): protobuf.Root;
|
|
4
4
|
export declare function encodeMessage(typeName: string, message: Record<string, unknown>): Uint8Array;
|
|
5
5
|
export declare function decodeMessage<T = Record<string, unknown>>(typeName: string, data: Uint8Array): T;
|
|
6
|
+
/**
|
|
7
|
+
* Diagnostic-only: walk AgentServerMessage.interaction_update(1).turn_ended(14)
|
|
8
|
+
* without going through our hand-written TurnEnded schema, so a `cache_write`/
|
|
9
|
+
* `reasoning_tokens` question can be checked against the raw wire instead of
|
|
10
|
+
* guessed. Confirmed field ids 1-5 (input/output/cache_read/cache_write/
|
|
11
|
+
* reasoning) decode correctly. Live captures routinely include `f4` explicitly
|
|
12
|
+
* as `0` while `f3` (cache_read) and `f5` (reasoning) are non-zero — Cursor's
|
|
13
|
+
* agent TurnEnded populates the field but does not report write counts here.
|
|
14
|
+
*/
|
|
15
|
+
export declare function debugWalkTurnEnded(payload: Uint8Array): string;
|
|
6
16
|
/**
|
|
7
17
|
* Decode a protobuf sub-message from a frame payload that wraps it in
|
|
8
18
|
* a top-level field key + length varint. Skips the outer wrapper and
|
|
@@ -1209,6 +1209,62 @@ export function decodeMessage(typeName, data) {
|
|
|
1209
1209
|
const decoded = type.decode(data);
|
|
1210
1210
|
return type.toObject(decoded, { defaults: true, json: true, longs: Number });
|
|
1211
1211
|
}
|
|
1212
|
+
/** Schema-free walk of one message's top-level wire fields (no nested descent). */
|
|
1213
|
+
function readRawFields(buf) {
|
|
1214
|
+
const reader = protobuf.Reader.create(buf);
|
|
1215
|
+
const fields = [];
|
|
1216
|
+
while (reader.pos < reader.len) {
|
|
1217
|
+
const tag = reader.uint32();
|
|
1218
|
+
const field = tag >>> 3;
|
|
1219
|
+
const wireType = tag & 7;
|
|
1220
|
+
switch (wireType) {
|
|
1221
|
+
case 0:
|
|
1222
|
+
fields.push({ field, wireType, varint: reader.uint64().toString() });
|
|
1223
|
+
break;
|
|
1224
|
+
case 1:
|
|
1225
|
+
fields.push({ field, wireType, fixed: reader.fixed64().toString() });
|
|
1226
|
+
break;
|
|
1227
|
+
case 2:
|
|
1228
|
+
fields.push({ field, wireType, bytes: reader.bytes() });
|
|
1229
|
+
break;
|
|
1230
|
+
case 5:
|
|
1231
|
+
fields.push({ field, wireType, fixed: reader.fixed32() });
|
|
1232
|
+
break;
|
|
1233
|
+
default:
|
|
1234
|
+
// Groups (3/4) aren't used on this wire; bail rather than desync the reader.
|
|
1235
|
+
return fields;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
return fields;
|
|
1239
|
+
}
|
|
1240
|
+
/**
|
|
1241
|
+
* Diagnostic-only: walk AgentServerMessage.interaction_update(1).turn_ended(14)
|
|
1242
|
+
* without going through our hand-written TurnEnded schema, so a `cache_write`/
|
|
1243
|
+
* `reasoning_tokens` question can be checked against the raw wire instead of
|
|
1244
|
+
* guessed. Confirmed field ids 1-5 (input/output/cache_read/cache_write/
|
|
1245
|
+
* reasoning) decode correctly. Live captures routinely include `f4` explicitly
|
|
1246
|
+
* as `0` while `f3` (cache_read) and `f5` (reasoning) are non-zero — Cursor's
|
|
1247
|
+
* agent TurnEnded populates the field but does not report write counts here.
|
|
1248
|
+
*/
|
|
1249
|
+
export function debugWalkTurnEnded(payload) {
|
|
1250
|
+
try {
|
|
1251
|
+
const top = readRawFields(payload);
|
|
1252
|
+
const iuField = top.find((f) => f.field === 1 && f.wireType === 2);
|
|
1253
|
+
if (!iuField?.bytes)
|
|
1254
|
+
return "(no interaction_update field)";
|
|
1255
|
+
const iu = readRawFields(iuField.bytes);
|
|
1256
|
+
const teField = iu.find((f) => f.field === 14 && f.wireType === 2);
|
|
1257
|
+
if (!teField?.bytes)
|
|
1258
|
+
return "(no turn_ended field in interaction_update)";
|
|
1259
|
+
const te = readRawFields(teField.bytes);
|
|
1260
|
+
return te
|
|
1261
|
+
.map((f) => `f${f.field}:wt${f.wireType}=${f.varint ?? f.fixed ?? (f.bytes ? `bytes[${f.bytes.length}]` : "?")}`)
|
|
1262
|
+
.join(" ");
|
|
1263
|
+
}
|
|
1264
|
+
catch (e) {
|
|
1265
|
+
return `(walk failed: ${e.message})`;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1212
1268
|
/**
|
|
1213
1269
|
* Decode a protobuf sub-message from a frame payload that wraps it in
|
|
1214
1270
|
* a top-level field key + length varint. Skips the outer wrapper and
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -158,6 +158,13 @@ export type ToolResultInput = {
|
|
|
158
158
|
resultMetadata?: Record<string, unknown>;
|
|
159
159
|
/** Structured shell completion captured by the OpenCode plugin hook. */
|
|
160
160
|
shellOutcome?: CursorShellOutcome;
|
|
161
|
+
/**
|
|
162
|
+
* Workspace root for result shapes that must name a directory
|
|
163
|
+
* (grep_result workspace_results keys, ls_result directory_tree_root).
|
|
164
|
+
* Must not fall back to process.cwd() — the OpenCode 2.0 daemon's cwd is
|
|
165
|
+
* often $HOME and would re-advertise the wrong folder to the model.
|
|
166
|
+
*/
|
|
167
|
+
workspaceRoot?: string;
|
|
161
168
|
};
|
|
162
169
|
/**
|
|
163
170
|
* Build one or more ExecClientMessage frames for a tool result.
|
|
@@ -195,7 +202,7 @@ export declare function unwrapReadOutput(output: string): string;
|
|
|
195
202
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
196
203
|
* server accepts (verified against agent.v1 wire captures).
|
|
197
204
|
*/
|
|
198
|
-
export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>, shellOutcome?: CursorShellOutcome): Record<string, unknown>;
|
|
205
|
+
export declare function buildTypedExecResult(resultField: string, output: string, error?: string, toolName?: string, resultMetadata?: Record<string, unknown>, shellOutcome?: CursorShellOutcome, workspaceRoot?: string): Record<string, unknown>;
|
|
199
206
|
export declare function buildToolCallPart(execMsg: ParsedExecRequest, sessionId: string): {
|
|
200
207
|
toolCallId: string;
|
|
201
208
|
toolName: string;
|
package/dist/protocol/tools.js
CHANGED
|
@@ -976,7 +976,7 @@ export function buildExecClientMessages(input) {
|
|
|
976
976
|
id: input.execId,
|
|
977
977
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
978
978
|
};
|
|
979
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome);
|
|
979
|
+
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot);
|
|
980
980
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
981
981
|
exec_client_message: clientMsg,
|
|
982
982
|
}));
|
|
@@ -1063,7 +1063,12 @@ export function unwrapReadOutput(output) {
|
|
|
1063
1063
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
1064
1064
|
* server accepts (verified against agent.v1 wire captures).
|
|
1065
1065
|
*/
|
|
1066
|
-
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome) {
|
|
1066
|
+
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
1067
|
+
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
1068
|
+
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
1069
|
+
const resultRoot = typeof workspaceRoot === "string" && workspaceRoot.trim()
|
|
1070
|
+
? path.resolve(workspaceRoot)
|
|
1071
|
+
: undefined;
|
|
1067
1072
|
switch (resultField) {
|
|
1068
1073
|
case "read_result": {
|
|
1069
1074
|
const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
|
|
@@ -1094,7 +1099,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1094
1099
|
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
1095
1100
|
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
1096
1101
|
const files = extractPathLines(output);
|
|
1097
|
-
const cwd =
|
|
1102
|
+
const cwd = resultRoot ?? "";
|
|
1098
1103
|
return {
|
|
1099
1104
|
success: {
|
|
1100
1105
|
pattern: "",
|
|
@@ -1184,7 +1189,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1184
1189
|
case "ls_result": {
|
|
1185
1190
|
if (error)
|
|
1186
1191
|
return { error: { path: "", error } };
|
|
1187
|
-
const rootPath =
|
|
1192
|
+
const rootPath = resultRoot ?? "";
|
|
1188
1193
|
const entries = extractPathLines(output);
|
|
1189
1194
|
return {
|
|
1190
1195
|
success: {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped workspace directory for the OpenCode 2.0 plugin.
|
|
3
|
+
*
|
|
4
|
+
* The classic plugin learns the project directory from `input.directory`,
|
|
5
|
+
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
|
+
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
|
+
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
+
* The only place the 2.0 runtime names a session's actual directory is
|
|
9
|
+
* `SessionInfo.location.directory`, reachable from `session.hook("context")`'s
|
|
10
|
+
* `sessionID` via `ctx.session.get()`. Record it there and let the provider
|
|
11
|
+
* read it back by session id, same mechanism as `compaction-marker.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function markSessionDirectory(sessionID: string, directory: string | undefined): void;
|
|
16
|
+
export declare function getSessionDirectory(sessionID: string | undefined): string | undefined;
|
|
17
|
+
export declare function clearSessionDirectories(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped workspace directory for the OpenCode 2.0 plugin.
|
|
3
|
+
*
|
|
4
|
+
* The classic plugin learns the project directory from `input.directory`,
|
|
5
|
+
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
|
+
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
|
+
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
+
* The only place the 2.0 runtime names a session's actual directory is
|
|
9
|
+
* `SessionInfo.location.directory`, reachable from `session.hook("context")`'s
|
|
10
|
+
* `sessionID` via `ctx.session.get()`. Record it there and let the provider
|
|
11
|
+
* read it back by session id, same mechanism as `compaction-marker.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_TRACKED_SESSIONS = 256;
|
|
16
|
+
const sessionDirectories = new Map();
|
|
17
|
+
export function markSessionDirectory(sessionID, directory) {
|
|
18
|
+
if (!sessionID || !directory)
|
|
19
|
+
return;
|
|
20
|
+
// Re-insert to keep insertion order meaningful for the eviction below.
|
|
21
|
+
sessionDirectories.delete(sessionID);
|
|
22
|
+
sessionDirectories.set(sessionID, directory);
|
|
23
|
+
while (sessionDirectories.size > MAX_TRACKED_SESSIONS) {
|
|
24
|
+
const oldest = sessionDirectories.keys().next().value;
|
|
25
|
+
if (oldest === undefined)
|
|
26
|
+
break;
|
|
27
|
+
sessionDirectories.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function getSessionDirectory(sessionID) {
|
|
31
|
+
return typeof sessionID === "string" ? sessionDirectories.get(sessionID) : undefined;
|
|
32
|
+
}
|
|
33
|
+
export function clearSessionDirectories() {
|
|
34
|
+
sessionDirectories.clear();
|
|
35
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type PendingExec = {
|
|
|
50
50
|
*/
|
|
51
51
|
bridged?: boolean;
|
|
52
52
|
};
|
|
53
|
-
export type ContinuationTerminalReason = "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed";
|
|
53
|
+
export type ContinuationTerminalReason = "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed" | "superseded-by-new-run" | "open-session-cap-exceeded";
|
|
54
54
|
export type SessionCloseReason = ContinuationTerminalReason | "ordinary-cleanup" | "turn-ended" | "initial-write-failed";
|
|
55
55
|
export type ContinuationClaim = {
|
|
56
56
|
session: CursorSession;
|
|
@@ -140,6 +140,7 @@ export type CursorSession = {
|
|
|
140
140
|
outputTokens: number;
|
|
141
141
|
cacheRead: number;
|
|
142
142
|
cacheWrite: number;
|
|
143
|
+
reasoningTokens: number;
|
|
143
144
|
};
|
|
144
145
|
/**
|
|
145
146
|
* True while a doStream pull() is actively reading this session's frames.
|
|
@@ -171,10 +172,12 @@ type SessionManagerOptions = {
|
|
|
171
172
|
activitySource?: SessionActivitySource;
|
|
172
173
|
tombstoneTtlMs?: number;
|
|
173
174
|
tombstoneLimit?: number;
|
|
175
|
+
maxOpenSessions?: number;
|
|
174
176
|
};
|
|
175
177
|
export declare class SessionManager {
|
|
176
178
|
private byExecId;
|
|
177
179
|
private sessions;
|
|
180
|
+
private byOpenCodeSessionId;
|
|
178
181
|
private tombstones;
|
|
179
182
|
private readonly now;
|
|
180
183
|
private readonly setTimer;
|
|
@@ -182,8 +185,17 @@ export declare class SessionManager {
|
|
|
182
185
|
private readonly activitySource;
|
|
183
186
|
private readonly tombstoneTtlMs;
|
|
184
187
|
private readonly tombstoneLimit;
|
|
188
|
+
private readonly maxOpenSessions;
|
|
185
189
|
constructor(options?: SessionManagerOptions);
|
|
186
190
|
registerSession(session: CursorSession): void;
|
|
191
|
+
private isPumping;
|
|
192
|
+
/**
|
|
193
|
+
* Force-close the oldest idle open session(s) once registration exceeds the
|
|
194
|
+
* cap. Never closes a session with a live pull() mid-await (see the same
|
|
195
|
+
* guard in the supersede check above) — an unclosable backlog of genuinely
|
|
196
|
+
* active sessions just means the cap can't be enforced until one finishes.
|
|
197
|
+
*/
|
|
198
|
+
private enforceOpenSessionCap;
|
|
187
199
|
recordSemanticProgress(session: CursorSession, at?: number): void;
|
|
188
200
|
recordHeartbeatWrite(session: CursorSession): void;
|
|
189
201
|
/** Register that `session` is awaiting a result for `execId`. */
|
package/dist/session.js
CHANGED
|
@@ -9,6 +9,11 @@ export const DEFAULT_CONTINUATION_POLICY = {
|
|
|
9
9
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
10
10
|
const DEFAULT_TOMBSTONE_TTL_MS = 15 * 60_000;
|
|
11
11
|
const DEFAULT_TOMBSTONE_LIMIT = 1_024;
|
|
12
|
+
// Well below Cursor's server-side concurrent-Run ceiling per HTTP/2 connection,
|
|
13
|
+
// so a caller that keeps abandoning Runs instead of delivering continuations
|
|
14
|
+
// gets corrected here instead of piling up open streams until Cursor's server
|
|
15
|
+
// force-closes the whole shared connection.
|
|
16
|
+
const DEFAULT_MAX_OPEN_SESSIONS = 24;
|
|
12
17
|
function positiveInteger(name, value, fallback) {
|
|
13
18
|
const resolved = value === undefined ? fallback : value;
|
|
14
19
|
if (typeof resolved !== "number" ||
|
|
@@ -50,6 +55,10 @@ export class SessionManager {
|
|
|
50
55
|
// counters per stream) coexist instead of overwriting each other.
|
|
51
56
|
byExecId = new Map();
|
|
52
57
|
sessions = new Set();
|
|
58
|
+
// Most recently registered open session per OpenCode session id. A second
|
|
59
|
+
// registration for the same id means the caller started a fresh Run instead
|
|
60
|
+
// of continuing the held-open one — the prior entry is stale and superseded.
|
|
61
|
+
byOpenCodeSessionId = new Map();
|
|
53
62
|
tombstones = new Map();
|
|
54
63
|
now;
|
|
55
64
|
setTimer;
|
|
@@ -57,6 +66,7 @@ export class SessionManager {
|
|
|
57
66
|
activitySource;
|
|
58
67
|
tombstoneTtlMs;
|
|
59
68
|
tombstoneLimit;
|
|
69
|
+
maxOpenSessions;
|
|
60
70
|
constructor(options = {}) {
|
|
61
71
|
this.now = options.now ?? Date.now;
|
|
62
72
|
this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
@@ -64,6 +74,7 @@ export class SessionManager {
|
|
|
64
74
|
this.activitySource = options.activitySource ?? sessionActivity;
|
|
65
75
|
this.tombstoneTtlMs = positiveInteger("tombstoneTtlMs", options.tombstoneTtlMs, DEFAULT_TOMBSTONE_TTL_MS);
|
|
66
76
|
this.tombstoneLimit = positiveInteger("tombstoneLimit", options.tombstoneLimit, DEFAULT_TOMBSTONE_LIMIT);
|
|
77
|
+
this.maxOpenSessions = positiveInteger("maxOpenSessions", options.maxOpenSessions, DEFAULT_MAX_OPEN_SESSIONS);
|
|
67
78
|
}
|
|
68
79
|
registerSession(session) {
|
|
69
80
|
if (session.closed)
|
|
@@ -84,12 +95,66 @@ export class SessionManager {
|
|
|
84
95
|
session.lastHeartbeatWriteAt ??= this.now();
|
|
85
96
|
session.semanticDeadlineAt ??= this.now() + session.policy.semanticIdleMs;
|
|
86
97
|
this.sessions.add(session);
|
|
98
|
+
if (session.openCodeSessionId) {
|
|
99
|
+
const prior = this.byOpenCodeSessionId.get(session.openCodeSessionId);
|
|
100
|
+
if (prior && prior !== session && !prior.closed) {
|
|
101
|
+
if (this.isPumping(prior)) {
|
|
102
|
+
// A live pull() is mid-await on this stream (nextFrameWithSemanticDeadline
|
|
103
|
+
// or the raw frame iterator). Forcing close() here would reject that await
|
|
104
|
+
// and propagate a real error to whatever still holds the ReadableStream —
|
|
105
|
+
// exactly the kind of self-inflicted failure this cap is meant to prevent.
|
|
106
|
+
// Let it finish or expire on its own (hard deadline / heartbeat failure).
|
|
107
|
+
trace(`sessionManager.registerSession: openCodeSessionId=${session.openCodeSessionId} ` +
|
|
108
|
+
`has a still-pumping prior session ${prior.sessionId} (pending=${prior.pending.size}) ` +
|
|
109
|
+
`— leaving it to finish or expire naturally instead of interrupting it`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
trace(`sessionManager.registerSession: superseding stale session ${prior.sessionId} ` +
|
|
113
|
+
`(openCodeSessionId=${session.openCodeSessionId}, pending=${prior.pending.size}) ` +
|
|
114
|
+
`with new session ${session.sessionId}`);
|
|
115
|
+
this.close(prior, "superseded-by-new-run");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.byOpenCodeSessionId.set(session.openCodeSessionId, session);
|
|
119
|
+
}
|
|
120
|
+
this.enforceOpenSessionCap(session);
|
|
87
121
|
const unsubscribe = session.stream.onTerminal?.((event) => this.onStreamTerminal(session, event)) ?? (() => { });
|
|
88
122
|
if (session.closed)
|
|
89
123
|
unsubscribe();
|
|
90
124
|
else
|
|
91
125
|
session.terminalUnsubscribe = unsubscribe;
|
|
92
126
|
}
|
|
127
|
+
isPumping(session) {
|
|
128
|
+
return session.pumpOwner != null || session.pumpActive;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Force-close the oldest idle open session(s) once registration exceeds the
|
|
132
|
+
* cap. Never closes a session with a live pull() mid-await (see the same
|
|
133
|
+
* guard in the supersede check above) — an unclosable backlog of genuinely
|
|
134
|
+
* active sessions just means the cap can't be enforced until one finishes.
|
|
135
|
+
*/
|
|
136
|
+
enforceOpenSessionCap(justRegistered) {
|
|
137
|
+
while (this.sessions.size > this.maxOpenSessions) {
|
|
138
|
+
let oldest;
|
|
139
|
+
for (const candidate of this.sessions) {
|
|
140
|
+
if (candidate === justRegistered || this.isPumping(candidate))
|
|
141
|
+
continue;
|
|
142
|
+
if (!oldest || candidate.createdAt < oldest.createdAt)
|
|
143
|
+
oldest = candidate;
|
|
144
|
+
}
|
|
145
|
+
if (!oldest) {
|
|
146
|
+
trace(`sessionManager.registerSession: open session cap exceeded ` +
|
|
147
|
+
`(${this.sessions.size} > ${this.maxOpenSessions}) but no idle session is ` +
|
|
148
|
+
`available to close — all remaining sessions are actively pumping`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
trace(`sessionManager.registerSession: open session cap exceeded ` +
|
|
152
|
+
`(${this.sessions.size} > ${this.maxOpenSessions}), force-closing oldest idle session ` +
|
|
153
|
+
`${oldest.sessionId} (openCodeSessionId=${oldest.openCodeSessionId ?? "-"}, ` +
|
|
154
|
+
`ageMs=${this.now() - oldest.createdAt}, pending=${oldest.pending.size})`);
|
|
155
|
+
this.close(oldest, "open-session-cap-exceeded");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
93
158
|
recordSemanticProgress(session, at) {
|
|
94
159
|
if (session.closed)
|
|
95
160
|
return;
|
|
@@ -295,6 +360,10 @@ export class SessionManager {
|
|
|
295
360
|
session.displayToolCalls?.clear();
|
|
296
361
|
session.blobs?.clear();
|
|
297
362
|
this.sessions.delete(session);
|
|
363
|
+
if (session.openCodeSessionId &&
|
|
364
|
+
this.byOpenCodeSessionId.get(session.openCodeSessionId) === session) {
|
|
365
|
+
this.byOpenCodeSessionId.delete(session.openCodeSessionId);
|
|
366
|
+
}
|
|
298
367
|
try {
|
|
299
368
|
session.stream.destroy();
|
|
300
369
|
}
|
|
@@ -311,7 +380,7 @@ export class SessionManager {
|
|
|
311
380
|
closeUnlessPending(session) {
|
|
312
381
|
if (session.closed)
|
|
313
382
|
return true;
|
|
314
|
-
const pumpActive =
|
|
383
|
+
const pumpActive = this.isPumping(session);
|
|
315
384
|
if (session.pending.size > 0 || pumpActive) {
|
|
316
385
|
trace(`sessionManager.closeUnlessPending: KEEP open pendingCount=${session.pending.size} pumpActive=${pumpActive}`);
|
|
317
386
|
return false;
|