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.
- package/README.md +143 -11
- 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/agents.d.ts +5 -1
- package/dist/context/agents.js +90 -30
- package/dist/context/build.js +57 -8
- package/dist/context/env.js +6 -2
- package/dist/context/paths.d.ts +10 -0
- package/dist/context/paths.js +24 -0
- package/dist/context/plugins.js +16 -12
- package/dist/context/rules.js +26 -23
- package/dist/context/skills.js +8 -3
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +121 -35
- 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 +76 -1
- package/dist/protocol/tool-call-bridge.js +22 -2
- package/dist/protocol/tools.d.ts +57 -1
- package/dist/protocol/tools.js +277 -17
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +16 -2
- 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 +7 -2
package/dist/protocol/tools.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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;
|
package/dist/shell-timeout.d.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/dist/shell-timeout.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/usage.d.ts
ADDED
|
@@ -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
|
+
};
|