cursor-opencode-provider 0.2.8 → 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 (51) hide show
  1. package/README.md +143 -11
  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/agents.d.ts +5 -1
  7. package/dist/context/agents.js +90 -30
  8. package/dist/context/build.js +57 -8
  9. package/dist/context/env.js +6 -2
  10. package/dist/context/paths.d.ts +10 -0
  11. package/dist/context/paths.js +24 -0
  12. package/dist/context/plugins.js +16 -12
  13. package/dist/context/rules.js +26 -23
  14. package/dist/context/skills.js +8 -3
  15. package/dist/index.d.ts +1 -3
  16. package/dist/index.js +2 -8
  17. package/dist/language-model.js +121 -35
  18. package/dist/model-config.d.ts +15 -0
  19. package/dist/model-config.js +188 -0
  20. package/dist/opencode2/catalog.d.ts +33 -0
  21. package/dist/opencode2/catalog.js +85 -0
  22. package/dist/opencode2/integration.d.ts +23 -0
  23. package/dist/opencode2/integration.js +124 -0
  24. package/dist/opencode2/types.d.ts +316 -0
  25. package/dist/opencode2/types.js +18 -0
  26. package/dist/plugin-core.d.ts +27 -0
  27. package/dist/plugin-core.js +31 -0
  28. package/dist/plugin-opencode2.d.ts +3 -0
  29. package/dist/plugin-opencode2.js +293 -0
  30. package/dist/plugin-v2.d.ts +7 -0
  31. package/dist/plugin-v2.js +1 -15
  32. package/dist/plugin.d.ts +1 -15
  33. package/dist/plugin.js +11 -191
  34. package/dist/protocol/messages.d.ts +10 -0
  35. package/dist/protocol/messages.js +76 -1
  36. package/dist/protocol/tool-call-bridge.js +22 -2
  37. package/dist/protocol/tools.d.ts +57 -1
  38. package/dist/protocol/tools.js +277 -17
  39. package/dist/session-directory.d.ts +17 -0
  40. package/dist/session-directory.js +35 -0
  41. package/dist/session.d.ts +16 -2
  42. package/dist/session.js +70 -1
  43. package/dist/shell-timeout.d.ts +3 -1
  44. package/dist/shell-timeout.js +6 -2
  45. package/dist/usage.d.ts +30 -0
  46. package/dist/usage.js +77 -0
  47. package/dist/web-search-tool.d.ts +33 -0
  48. package/dist/web-search-tool.js +22 -0
  49. package/dist/web-tools.d.ts +9 -25
  50. package/dist/web-tools.js +34 -36
  51. package/package.json +7 -2
@@ -1,4 +1,6 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  import { encodeMessage } from "./messages.js";
3
5
  import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
4
6
  import { buildEnv } from "../context/env.js";
@@ -280,6 +282,200 @@ export function mapExecServerToToolName(execField) {
280
282
  export function mapToolNameToExecField(toolName) {
281
283
  return opencodeToolToCursor[toolName];
282
284
  }
285
+ const CURSOR_SUBAGENT_TYPE_TO_OPENCODE = {
286
+ generalPurpose: "general",
287
+ "general-purpose": "general",
288
+ general_purpose: "general",
289
+ general: "general",
290
+ unspecified: "general",
291
+ "cursor-guide": "explore",
292
+ cursor_guide: "explore",
293
+ "best-of-n-runner": "general",
294
+ best_of_n_runner: "general",
295
+ bash: "general",
296
+ shell: "general",
297
+ debug: "general",
298
+ computer_use: "general",
299
+ computerUse: "general",
300
+ browser_use: "general",
301
+ browserUse: "general",
302
+ media_review: "general",
303
+ mediaReview: "general",
304
+ watch_video: "general",
305
+ watchVideo: "general",
306
+ vm_setup_helper: "general",
307
+ vmSetupHelper: "general",
308
+ explore: "explore",
309
+ bugbot: "explore",
310
+ "security-review": "explore",
311
+ security_review: "explore",
312
+ };
313
+ const SUBAGENT_CATALOG_MARKER = "Available agent types and the tools they have access to:";
314
+ function parseSubagentDescriptionCatalog(description) {
315
+ if (!description)
316
+ return { found: false, agents: [] };
317
+ const marker = description.indexOf(SUBAGENT_CATALOG_MARKER);
318
+ if (marker < 0)
319
+ return { found: false, agents: [] };
320
+ const agents = [];
321
+ const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
322
+ let started = false;
323
+ for (const line of lines) {
324
+ const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
325
+ if (!match) {
326
+ if (started && line.trim())
327
+ break;
328
+ continue;
329
+ }
330
+ started = true;
331
+ const name = match[1].trim().replace(/^`|`$/g, "");
332
+ if (!name)
333
+ continue;
334
+ const agentDescription = match[2].trim();
335
+ agents.push({
336
+ name,
337
+ ...(agentDescription ? { description: agentDescription } : {}),
338
+ });
339
+ }
340
+ return { found: true, agents };
341
+ }
342
+ function subagentTypeEnumValues(schema) {
343
+ const out = new Set();
344
+ const seen = new Set();
345
+ const visit = (value, propertyName) => {
346
+ if (!value || typeof value !== "object")
347
+ return;
348
+ if (seen.has(value))
349
+ return;
350
+ seen.add(value);
351
+ if (Array.isArray(value)) {
352
+ for (const item of value)
353
+ visit(item, propertyName);
354
+ return;
355
+ }
356
+ const record = value;
357
+ if (propertyName === "subagent_type" && Array.isArray(record.enum)) {
358
+ for (const item of record.enum) {
359
+ if (typeof item === "string" && item)
360
+ out.add(item);
361
+ }
362
+ }
363
+ for (const [key, child] of Object.entries(record))
364
+ visit(child, key);
365
+ };
366
+ visit(schema);
367
+ return [...out];
368
+ }
369
+ /** Extract the current host's permission-filtered Task/Actor recipient catalog. */
370
+ export function extractHostSubagentCatalog(tools) {
371
+ const executorTool = tools.find((tool) => tool.name === "actor") ??
372
+ tools.find((tool) => tool.name === "task");
373
+ if (!executorTool)
374
+ return { agents: [], complete: true };
375
+ const described = parseSubagentDescriptionCatalog(executorTool.description);
376
+ const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
377
+ const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
378
+ // MiMo's Actor enum is stricter than its prose catalog (`mode: subagent`
379
+ // rather than every non-primary agent), so structured names are authoritative.
380
+ const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
381
+ return {
382
+ executor: executorTool.name,
383
+ agents: [...names].map((name) => ({
384
+ name,
385
+ ...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
386
+ })),
387
+ complete: enumNames.length > 0 || described.found,
388
+ };
389
+ }
390
+ const GENERIC_CURSOR_SUBAGENT_TYPES = new Set([
391
+ "generalPurpose",
392
+ "general-purpose",
393
+ "general_purpose",
394
+ "general",
395
+ "unspecified",
396
+ ]);
397
+ function cursorSubagentCandidates(subagentType) {
398
+ if (GENERIC_CURSOR_SUBAGENT_TYPES.has(subagentType))
399
+ return ["general"];
400
+ if (subagentType === "cursor-guide" || subagentType === "cursor_guide") {
401
+ return ["scout", "explore", "general"];
402
+ }
403
+ if (subagentType === "explore" ||
404
+ subagentType === "bugbot" ||
405
+ subagentType === "security-review" ||
406
+ subagentType === "security_review") {
407
+ return ["explore", "general"];
408
+ }
409
+ return ["general"];
410
+ }
411
+ /** Resolve a Cursor subtype against the agents the host can spawn this turn. */
412
+ export function resolveCursorSubagentType(subagentType, catalog) {
413
+ const available = new Set(catalog?.agents.map((agent) => agent.name) ?? []);
414
+ // Explicit host/custom names win. `unspecified` and general aliases are
415
+ // intentionally excluded so they always select the host's generic agent.
416
+ if (!GENERIC_CURSOR_SUBAGENT_TYPES.has(subagentType) && available.has(subagentType)) {
417
+ return subagentType;
418
+ }
419
+ const candidates = cursorSubagentCandidates(subagentType);
420
+ for (const candidate of candidates) {
421
+ if (available.has(candidate))
422
+ return candidate;
423
+ }
424
+ if (catalog?.complete)
425
+ return undefined;
426
+ return candidates[0];
427
+ }
428
+ /**
429
+ * Cursor's native Task/subagent protocol uses Cursor-owned subtype names while
430
+ * OpenCode-family hosts execute named agents. Map known Cursor built-ins to
431
+ * the closest host agent and preserve unknown values so user-defined OpenCode
432
+ * agents can still be called by exact name.
433
+ */
434
+ export function mapCursorSubagentTypeToOpenCode(subagentType) {
435
+ return CURSOR_SUBAGENT_TYPE_TO_OPENCODE[subagentType] ?? subagentType;
436
+ }
437
+ /** Resolve a native Cursor subagent request to this turn's executor and catalog. */
438
+ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catalog) {
439
+ if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
440
+ return;
441
+ const advertised = new Set(advertisedToolNames);
442
+ const executor = advertised.has("actor") ? "actor" : advertised.has("task") ? "task" : undefined;
443
+ if (!executor)
444
+ return;
445
+ const description = str(parsed.args.description) ?? "";
446
+ const prompt = str(parsed.args.prompt) ?? "";
447
+ const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
448
+ str(parsed.args.subagent_type) ?? "";
449
+ const subagentType = resolveCursorSubagentType(cursorSubagentType, catalog);
450
+ if (!subagentType) {
451
+ const available = catalog?.agents.map((agent) => agent.name).join(", ") || "none";
452
+ parsed.localError =
453
+ `Cursor subagent '${cursorSubagentType}' has no compatible host agent. ` +
454
+ `Available subagents: ${available}.`;
455
+ return;
456
+ }
457
+ const resumeAgentId = str(parsed.args.task_id);
458
+ parsed.toolName = executor;
459
+ if (executor === "task") {
460
+ parsed.args = {
461
+ description,
462
+ prompt,
463
+ subagent_type: subagentType,
464
+ ...(resumeAgentId ? { task_id: resumeAgentId } : {}),
465
+ ...(parsed.args.background === true ? { background: true } : {}),
466
+ };
467
+ return;
468
+ }
469
+ parsed.args = {
470
+ operation: {
471
+ action: parsed.args.background === true ? "spawn" : "run",
472
+ description,
473
+ prompt,
474
+ subagent_type: subagentType,
475
+ ...(resumeAgentId ? { actor_id: resumeAgentId } : {}),
476
+ },
477
+ };
478
+ }
283
479
  export function parseExecServerMessage(msg) {
284
480
  const id = msg.id;
285
481
  if (id === undefined)
@@ -358,6 +554,9 @@ export function parseExecServerMessage(msg) {
358
554
  toolName: "task",
359
555
  args,
360
556
  resultField,
557
+ resultMetadata: cursorSubagentType
558
+ ? { cursor_subagent_type: cursorSubagentType }
559
+ : undefined,
361
560
  localError: prompt && cursorSubagentType
362
561
  ? undefined
363
562
  : "Cursor subagent request is missing a required prompt or subagent type.",
@@ -595,19 +794,6 @@ function describeSubagentTask(prompt, subagentType) {
595
794
  return words.join(" ");
596
795
  return `${subagentType || "Delegated"} task`;
597
796
  }
598
- function mapCursorSubagentTypeToOpenCode(subagentType) {
599
- // Cursor's built-in general agent uses a camelCase protocol identifier, and
600
- // its native Bugbot reviewer has no OpenCode-specific agent definition.
601
- // OpenCode `general` is the general-purpose equivalent; `explore` preserves
602
- // Bugbot's review-only/read-oriented semantics while retaining grep/read/bash.
603
- // Preserve every other value so `explore` and configured custom agents keep
604
- // their exact advertised names.
605
- if (subagentType === "generalPurpose")
606
- return "general";
607
- if (subagentType === "bugbot")
608
- return "explore";
609
- return subagentType;
610
- }
611
797
  function shellQuote(s) {
612
798
  return `'${s.replace(/'/g, `'\\''`)}'`;
613
799
  }
@@ -667,6 +853,75 @@ function findOneOfVariant(msg, candidates) {
667
853
  }
668
854
  return undefined;
669
855
  }
856
+ // ── Pre-execution read validation (Cursor LocalReadExecutor parity) ──
857
+ /** Expand a leading `~` to the home directory, matching Cursor's untildify. */
858
+ function untildify(input) {
859
+ return input.replace(/^~(?=$|\/|\\)/, os.homedir());
860
+ }
861
+ /**
862
+ * Resolve a read target the way Cursor's LocalReadExecutor does before it
863
+ * stats the file: untildify, then resolve a relative path against the workspace
864
+ * root (`env.workspace_paths[0]`), else resolve as-is. Kept in lockstep with
865
+ * agent utils `resolvePath(path, workspaceRoot)`.
866
+ */
867
+ export function resolveReadTargetPath(requested, workspaceRoot) {
868
+ const expanded = untildify(requested);
869
+ if (workspaceRoot && !path.isAbsolute(expanded)) {
870
+ return path.resolve(workspaceRoot, expanded);
871
+ }
872
+ return path.resolve(expanded);
873
+ }
874
+ /**
875
+ * A typed ReadResult oneof case for a read target that fails Cursor's
876
+ * pre-execution validation, or undefined when the path is a readable file the
877
+ * tool should actually read. Mirrors LocalReadExecutor exactly:
878
+ * - missing path (ENOENT/ENOTDIR) → file_not_found
879
+ * - directory → invalid_file "Path is a directory, not a file"
880
+ * - socket/fifo/etc (not a file) → invalid_file "Path is neither a file nor a directory"
881
+ * EACCES/EPERM (exists but unreadable) and any other stat error return undefined
882
+ * so the call proceeds to OpenCode and a genuine permission decision is never
883
+ * masked. Never rejects a non-existent *write/edit* target — this is read-only.
884
+ */
885
+ export function classifyMissingReadTarget(absolutePath) {
886
+ let stat;
887
+ try {
888
+ stat = fs.statSync(absolutePath);
889
+ }
890
+ catch (e) {
891
+ const code = e.code;
892
+ if (code === "ENOENT" || code === "ENOTDIR") {
893
+ return { file_not_found: { path: absolutePath } };
894
+ }
895
+ return undefined;
896
+ }
897
+ if (stat.isDirectory()) {
898
+ return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
899
+ }
900
+ if (!stat.isFile()) {
901
+ return {
902
+ invalid_file: { path: absolutePath, reason: "Path is neither a file nor a directory" },
903
+ };
904
+ }
905
+ return undefined;
906
+ }
907
+ /**
908
+ * Encode a pre-execution read rejection as the exact frames Cursor's client
909
+ * emits: the ExecClientMessage carrying the typed ReadResult oneof case, then
910
+ * the ACM #5 stream_close for that id. `readResult` is the case object from
911
+ * classifyMissingReadTarget (e.g. `{ file_not_found: { path } }`).
912
+ */
913
+ export function buildReadRejectionMessages(execId, readResult) {
914
+ return [
915
+ encodeMessage("AgentClientMessage", {
916
+ exec_client_message: {
917
+ id: execId,
918
+ local_execution_time_ms: 0,
919
+ read_result: readResult,
920
+ },
921
+ }),
922
+ buildExecStreamClose(execId),
923
+ ];
924
+ }
670
925
  /**
671
926
  * Build one or more ExecClientMessage frames for a tool result.
672
927
  * Shell replies are a sequence of ShellStream oneofs under the same id —
@@ -721,7 +976,7 @@ export function buildExecClientMessages(input) {
721
976
  id: input.execId,
722
977
  local_execution_time_ms: input.executionTimeMs ?? 0,
723
978
  };
724
- 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);
725
980
  frames.push(encodeMessage("AgentClientMessage", {
726
981
  exec_client_message: clientMsg,
727
982
  }));
@@ -808,7 +1063,12 @@ export function unwrapReadOutput(output) {
808
1063
  * OpenCode returns free-form text; we wrap it in the minimal success shape the
809
1064
  * server accepts (verified against agent.v1 wire captures).
810
1065
  */
811
- 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;
812
1072
  switch (resultField) {
813
1073
  case "read_result": {
814
1074
  const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
@@ -839,7 +1099,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
839
1099
  // Prefer files_with_matches: OpenCode glob/grep often returns path lists.
840
1100
  // Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
841
1101
  const files = extractPathLines(output);
842
- const cwd = process.cwd();
1102
+ const cwd = resultRoot ?? "";
843
1103
  return {
844
1104
  success: {
845
1105
  pattern: "",
@@ -929,7 +1189,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
929
1189
  case "ls_result": {
930
1190
  if (error)
931
1191
  return { error: { path: "", error } };
932
- const rootPath = process.cwd();
1192
+ const rootPath = resultRoot ?? "";
933
1193
  const entries = extractPathLines(output);
934
1194
  return {
935
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
@@ -1,7 +1,7 @@
1
1
  import type { BidiStream } from "./transport/connect.js";
2
2
  import { type CursorProviderError } from "./errors.js";
3
3
  import { type SessionActivitySource } from "./activity.js";
4
- import type { ToolAliasRegistry } from "./protocol/tools.js";
4
+ import type { HostSubagentCatalog, ToolAliasRegistry } from "./protocol/tools.js";
5
5
  export type Frame = {
6
6
  flags: number;
7
7
  payload: Uint8Array;
@@ -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;
@@ -120,6 +120,8 @@ export type CursorSession = {
120
120
  toolDescriptors: Array<Record<string, unknown>>;
121
121
  /** Cursor-facing web alias → exact executable host tool for this Run. */
122
122
  toolAliases?: ToolAliasRegistry;
123
+ /** Spawnable host agents extracted from this turn's Task/Actor definition. */
124
+ subagentCatalog?: HostSubagentCatalog;
123
125
  /** Full RequestContext for exec #10 replies. */
124
126
  requestContext: Record<string, unknown>;
125
127
  /**
@@ -138,6 +140,7 @@ export type CursorSession = {
138
140
  outputTokens: number;
139
141
  cacheRead: number;
140
142
  cacheWrite: number;
143
+ reasoningTokens: number;
141
144
  };
142
145
  /**
143
146
  * True while a doStream pull() is actively reading this session's frames.
@@ -169,10 +172,12 @@ type SessionManagerOptions = {
169
172
  activitySource?: SessionActivitySource;
170
173
  tombstoneTtlMs?: number;
171
174
  tombstoneLimit?: number;
175
+ maxOpenSessions?: number;
172
176
  };
173
177
  export declare class SessionManager {
174
178
  private byExecId;
175
179
  private sessions;
180
+ private byOpenCodeSessionId;
176
181
  private tombstones;
177
182
  private readonly now;
178
183
  private readonly setTimer;
@@ -180,8 +185,17 @@ export declare class SessionManager {
180
185
  private readonly activitySource;
181
186
  private readonly tombstoneTtlMs;
182
187
  private readonly tombstoneLimit;
188
+ private readonly maxOpenSessions;
183
189
  constructor(options?: SessionManagerOptions);
184
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;
185
199
  recordSemanticProgress(session: CursorSession, at?: number): void;
186
200
  recordHeartbeatWrite(session: CursorSession): void;
187
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;
@@ -73,7 +73,9 @@ export declare function buildBackgroundShellCommand(command: string): string;
73
73
  * The classic hook replaces it with the original command (bash/zsh) or the
74
74
  * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
75
75
  */
76
- export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>): void;
76
+ export declare function prepareCursorShellArgs(toolCallId: string, args: Record<string, unknown>, options?: {
77
+ preferWrapperCommand?: boolean;
78
+ }): void;
77
79
  /** Restore the model-facing command in OpenCode's completed tool title. */
78
80
  export declare function cursorShellOriginalCommand(toolCallId: string): string | undefined;
79
81
  /** Drop injector temp files for a finished/abandoned Cursor shell call. */
@@ -213,7 +213,7 @@ function ensureShellEnvWrap(toolCallId, policy) {
213
213
  * The classic hook replaces it with the original command (bash/zsh) or the
214
214
  * shorter wrapper-file command (sh/dash), avoiding duplicate execution.
215
215
  */
216
- export function prepareCursorShellArgs(toolCallId, args) {
216
+ export function prepareCursorShellArgs(toolCallId, args, options = {}) {
217
217
  const policy = policies.get(toolCallId);
218
218
  if (!policy)
219
219
  return;
@@ -226,7 +226,11 @@ export function prepareCursorShellArgs(toolCallId, args) {
226
226
  args.timeout = Math.max(OPENCODE_TIMEOUT_GRACE_MS, policy.timeoutMs + OPENCODE_TIMEOUT_GRACE_MS);
227
227
  }
228
228
  const shellKind = resolveCursorShellKind();
229
- if (process.platform === "win32" || shellKind === "bash" || shellKind === "zsh") {
229
+ // OpenCode 2.0 has no `shell.env` hook, so bash/zsh cannot be wrapped by
230
+ // sourcing an injector. Callers there opt into the wrapper-file command —
231
+ // the same mechanism sh/dash already use — which needs no env injection.
232
+ const envInjectable = !options.preferWrapperCommand && (shellKind === "bash" || shellKind === "zsh");
233
+ if (process.platform === "win32" || envInjectable) {
230
234
  // Native Windows PowerShell/cmd wrapping remains unsupported; do not emit
231
235
  // a POSIX /bin/sh command there. Git Bash still uses the env path above.
232
236
  args.command = policy.command;
@@ -0,0 +1,30 @@
1
+ import type { LanguageModelV3Usage } from "@ai-sdk/provider";
2
+ /** Non-negative integer counter from a Cursor `turn_ended` field. */
3
+ export declare function turnEndedCounter(te: Record<string, unknown>, key: string): number;
4
+ /**
5
+ * Map authoritative Cursor TurnEnded counters to AI SDK V3 usage (Kilo/OpenCode
6
+ * flatten via input total = noCache + cacheRead + cacheWrite).
7
+ *
8
+ * Contract: `input_tokens` is non-cached input; cache fields are separate.
9
+ * Cursor's agent wire currently sends `cache_write=0` even when later turns
10
+ * report large `cache_read`; we still forward the counter when non-zero.
11
+ * If production semantics differ (input_tokens already total), adjust here.
12
+ */
13
+ export declare function buildLanguageModelV3UsageFromTurnEnded(te: Record<string, unknown>): LanguageModelV3Usage;
14
+ export type CursorUsageEstimate = {
15
+ inputTokens: number;
16
+ outputTokens: number;
17
+ cacheRead: number;
18
+ cacheWrite: number;
19
+ reasoningTokens: number;
20
+ };
21
+ /** Mid-turn finish (e.g. tool-calls) before `turn_ended` — keep non-zero estimates. */
22
+ export declare function buildLanguageModelV3UsageFromEstimate(est: CursorUsageEstimate, promptTokens: number, outputChars: number, estimateCharsToTokens: (chars: number) => number): LanguageModelV3Usage;
23
+ /** Mirror Kilo `Session.getUsage` / AI SDK flat fields from V3 nested usage. */
24
+ export declare function kiloShapedUsageFromV3(usage: LanguageModelV3Usage): {
25
+ inputTokens: number;
26
+ outputTokens: number;
27
+ reasoningTokens: number;
28
+ cacheReadInputTokens: number;
29
+ cacheWriteInputTokens: number;
30
+ };