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.
Files changed (42) hide show
  1. package/README.md +128 -8
  2. package/dist/auth.d.ts +7 -0
  3. package/dist/auth.js +9 -0
  4. package/dist/compaction-marker.d.ts +14 -0
  5. package/dist/compaction-marker.js +36 -0
  6. package/dist/context/env.js +6 -2
  7. package/dist/index.d.ts +1 -3
  8. package/dist/index.js +2 -8
  9. package/dist/language-model.js +37 -33
  10. package/dist/model-config.d.ts +15 -0
  11. package/dist/model-config.js +188 -0
  12. package/dist/opencode2/catalog.d.ts +33 -0
  13. package/dist/opencode2/catalog.js +85 -0
  14. package/dist/opencode2/integration.d.ts +23 -0
  15. package/dist/opencode2/integration.js +124 -0
  16. package/dist/opencode2/types.d.ts +316 -0
  17. package/dist/opencode2/types.js +18 -0
  18. package/dist/plugin-core.d.ts +27 -0
  19. package/dist/plugin-core.js +31 -0
  20. package/dist/plugin-opencode2.d.ts +3 -0
  21. package/dist/plugin-opencode2.js +293 -0
  22. package/dist/plugin-v2.d.ts +7 -0
  23. package/dist/plugin-v2.js +1 -15
  24. package/dist/plugin.d.ts +1 -15
  25. package/dist/plugin.js +11 -191
  26. package/dist/protocol/messages.d.ts +10 -0
  27. package/dist/protocol/messages.js +56 -0
  28. package/dist/protocol/tools.d.ts +8 -1
  29. package/dist/protocol/tools.js +9 -4
  30. package/dist/session-directory.d.ts +17 -0
  31. package/dist/session-directory.js +35 -0
  32. package/dist/session.d.ts +13 -1
  33. package/dist/session.js +70 -1
  34. package/dist/shell-timeout.d.ts +3 -1
  35. package/dist/shell-timeout.js +6 -2
  36. package/dist/usage.d.ts +30 -0
  37. package/dist/usage.js +77 -0
  38. package/dist/web-search-tool.d.ts +33 -0
  39. package/dist/web-search-tool.js +22 -0
  40. package/dist/web-tools.d.ts +9 -25
  41. package/dist/web-tools.js +34 -36
  42. package/package.json +8 -3
@@ -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 { createCursorLanguageModel } from "./language-model.js";
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
- import { type ModelInfo } from "./models.js";
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 { CURSOR_VARIANT_PARAMETERS_KEY, CURSOR_WIRE_MODEL_ID_KEY, readCache, discoverModels, isCacheFresh, parseCursorContextLimit } from "./models.js";
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-tools.js";
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: "sk-...",
266
+ placeholder: "crsr_...",
447
267
  validate(value) {
448
- if (!value.startsWith("sk-"))
449
- return "API key should start with sk-";
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
@@ -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;
@@ -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 = process.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 = process.cwd();
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 = session.pumpOwner != null || session.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;