cursor-opencode-provider 0.2.9 → 0.3.1
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 +139 -10
- package/dist/agent-url.js +2 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +9 -0
- package/dist/compaction-marker.d.ts +14 -0
- package/dist/compaction-marker.js +36 -0
- package/dist/context/env.js +6 -2
- package/dist/context/paths.js +3 -2
- package/dist/index.d.ts +1 -3
- package/dist/index.js +2 -8
- package/dist/language-model.js +51 -35
- package/dist/model-config.d.ts +15 -0
- package/dist/model-config.js +206 -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/apply-patch.d.ts +109 -0
- package/dist/protocol/apply-patch.js +163 -0
- package/dist/protocol/messages.d.ts +10 -0
- package/dist/protocol/messages.js +83 -1
- package/dist/protocol/tools.d.ts +24 -1
- package/dist/protocol/tools.js +262 -19
- package/dist/session-directory.d.ts +17 -0
- package/dist/session-directory.js +35 -0
- package/dist/session.d.ts +13 -1
- package/dist/session.js +70 -1
- package/dist/shell-timeout.d.ts +3 -1
- package/dist/shell-timeout.js +12 -4
- 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 +14 -9
package/dist/protocol/tools.js
CHANGED
|
@@ -7,6 +7,7 @@ import { buildEnv } from "../context/env.js";
|
|
|
7
7
|
import { ensureOpencodeProjectDir } from "../context/paths.js";
|
|
8
8
|
import { trace, traceRequestContextPaths } from "../debug.js";
|
|
9
9
|
import { cursorExecVariantByRequestName } from "./exec-variants.js";
|
|
10
|
+
import { APPLY_PATCH_TOOL, buildAddFilePatch, buildUpdateFilePatch, planSubstringEdit, } from "./apply-patch.js";
|
|
10
11
|
import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
|
|
11
12
|
// Exec variant field number whose reply is the server-initiated request_context
|
|
12
13
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
@@ -260,11 +261,15 @@ const CURSOR_INTERNAL_KEYS = new Set([
|
|
|
260
261
|
"close_stdin",
|
|
261
262
|
"output_notification",
|
|
262
263
|
"file_output_threshold_bytes",
|
|
264
|
+
// WriteArgs #4. A request for WriteSuccess.file_content_after_write, which we
|
|
265
|
+
// do not populate; harmless to omit, but it is not tool input.
|
|
263
266
|
"return_file_content_after_write",
|
|
264
|
-
"file_bytes",
|
|
265
|
-
"encoding_hint",
|
|
266
267
|
"ignore",
|
|
267
268
|
]);
|
|
269
|
+
// WriteArgs #5/#6 are deliberately NOT internal: `file_bytes` carries the file
|
|
270
|
+
// content itself whenever Cursor sends it instead of `file_text` (its own
|
|
271
|
+
// LocalWriteExecutor prefers bytes when non-empty), and `encoding_hint` is how
|
|
272
|
+
// that payload is decoded. The `write` mapping consumes both explicitly.
|
|
268
273
|
/** Required content fields where an empty string is meaningful (for example, truncating a file). */
|
|
269
274
|
const PRESERVE_EMPTY_STRING_KEYS = new Set([
|
|
270
275
|
"content",
|
|
@@ -476,6 +481,92 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
476
481
|
},
|
|
477
482
|
};
|
|
478
483
|
}
|
|
484
|
+
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
485
|
+
const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
486
|
+
/**
|
|
487
|
+
* Express a native Cursor write/edit as `apply_patch` when the host swapped the
|
|
488
|
+
* edit-tool family out from under us.
|
|
489
|
+
*
|
|
490
|
+
* OpenCode 1.x removes `edit` and `write` from the catalog for GPT models and
|
|
491
|
+
* advertises `apply_patch` in their place — see the module comment in
|
|
492
|
+
* `./apply-patch.ts` for the upstream reasoning and citations. Cursor keeps
|
|
493
|
+
* using its native write/edit exec channel regardless, so without this the
|
|
494
|
+
* request is refused as an unavailable tool and the model loses the ability to
|
|
495
|
+
* change files at all.
|
|
496
|
+
*
|
|
497
|
+
* Keyed purely off the advertised set: this is inert whenever the host offers
|
|
498
|
+
* `write`/`edit` normally, and equally inert when it offers neither those nor
|
|
499
|
+
* `apply_patch` (the caller's unavailable-tool rejection still applies).
|
|
500
|
+
*/
|
|
501
|
+
export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
|
|
502
|
+
if (parsed.toolName !== "write" && parsed.toolName !== "edit")
|
|
503
|
+
return;
|
|
504
|
+
const advertised = new Set(advertisedToolNames);
|
|
505
|
+
if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
|
|
506
|
+
return;
|
|
507
|
+
const requested = parsed.toolName;
|
|
508
|
+
const filePath = str(parsed.args.filePath);
|
|
509
|
+
const refuse = (reason) => {
|
|
510
|
+
parsed.toolName = APPLY_PATCH_TOOL;
|
|
511
|
+
parsed.args = {};
|
|
512
|
+
parsed.localError =
|
|
513
|
+
`Cursor ${requested} request cannot be expressed as an apply_patch call: ${reason}. ` +
|
|
514
|
+
"The host advertises `apply_patch` instead of `edit`/`write` for this model.";
|
|
515
|
+
};
|
|
516
|
+
if (!filePath) {
|
|
517
|
+
refuse("no target path was provided");
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
let patchText;
|
|
521
|
+
if (requested === "write") {
|
|
522
|
+
const content = stringValue(parsed.args.content);
|
|
523
|
+
if (content === undefined) {
|
|
524
|
+
refuse("no file content was provided");
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
// `*** Add File:` overwrites an existing target, so a whole-file write maps
|
|
528
|
+
// directly without reading the current contents or diffing.
|
|
529
|
+
patchText = buildAddFilePatch(filePath, content);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
const oldString = stringValue(parsed.args.oldString);
|
|
533
|
+
const newString = stringValue(parsed.args.newString);
|
|
534
|
+
if (oldString === undefined || newString === undefined) {
|
|
535
|
+
refuse("the replacement is missing its old or new text");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
// apply_patch matches whole lines, so a substring edit has to be widened to
|
|
539
|
+
// the lines it touches — which requires the file the model is editing.
|
|
540
|
+
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
541
|
+
let source;
|
|
542
|
+
try {
|
|
543
|
+
// Bounded because this read is synchronous on the Run pump. Cursor's own
|
|
544
|
+
// executor uses the same 50 MB threshold before reading a file it is
|
|
545
|
+
// about to write.
|
|
546
|
+
const size = fs.statSync(absolute).size;
|
|
547
|
+
if (size > MAX_EDIT_SOURCE_BYTES) {
|
|
548
|
+
refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
source = fs.readFileSync(absolute, "utf8");
|
|
552
|
+
}
|
|
553
|
+
catch (e) {
|
|
554
|
+
refuse(`the target file could not be read (${e.message})`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const plan = planSubstringEdit(source, oldString, newString, parsed.args.replaceAll === true);
|
|
558
|
+
if (!plan.ok) {
|
|
559
|
+
refuse(plan.reason);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
patchText = buildUpdateFilePatch(filePath, plan.chunks);
|
|
563
|
+
}
|
|
564
|
+
parsed.toolName = APPLY_PATCH_TOOL;
|
|
565
|
+
parsed.args = { patchText };
|
|
566
|
+
// apply_patch's output does not carry opencode `write`'s <path> tag, so keep
|
|
567
|
+
// the requested path for the typed Cursor result.
|
|
568
|
+
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
569
|
+
}
|
|
479
570
|
export function parseExecServerMessage(msg) {
|
|
480
571
|
const id = msg.id;
|
|
481
572
|
if (id === undefined)
|
|
@@ -707,7 +798,13 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
707
798
|
const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
|
|
708
799
|
if (filePath)
|
|
709
800
|
args.filePath = filePath;
|
|
710
|
-
|
|
801
|
+
// Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
|
|
802
|
+
// non-empty and only falls back to `file_text`; mirror that order so a
|
|
803
|
+
// byte-encoded write is not silently seen as empty content.
|
|
804
|
+
const bytes = bytesValue(cleaned.file_bytes) ?? bytesValue(cleaned.fileBytes);
|
|
805
|
+
const content = bytes !== undefined
|
|
806
|
+
? decodeWriteBytes(bytes, str(cleaned.encoding_hint) ?? str(cleaned.encodingHint))
|
|
807
|
+
: stringValue(cleaned.content) ?? stringValue(cleaned.file_text) ?? stringValue(cleaned.fileText);
|
|
711
808
|
if (content !== undefined)
|
|
712
809
|
args.content = content;
|
|
713
810
|
return { toolName: "write", args };
|
|
@@ -781,6 +878,32 @@ function str(v) {
|
|
|
781
878
|
function stringValue(v) {
|
|
782
879
|
return typeof v === "string" ? v : undefined;
|
|
783
880
|
}
|
|
881
|
+
/** A non-empty protobuf `bytes` field, or undefined when absent/empty. */
|
|
882
|
+
function bytesValue(v) {
|
|
883
|
+
if (v instanceof Uint8Array)
|
|
884
|
+
return v.length > 0 ? v : undefined;
|
|
885
|
+
if (Array.isArray(v) && v.every((b) => typeof b === "number")) {
|
|
886
|
+
return v.length > 0 ? Uint8Array.from(v) : undefined;
|
|
887
|
+
}
|
|
888
|
+
return undefined;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Decode `WriteArgs.file_bytes`. `encoding_hint` names the file's original
|
|
892
|
+
* encoding; OpenCode's `write` takes a string, so anything Node cannot decode
|
|
893
|
+
* falls back to UTF-8 rather than dropping the write.
|
|
894
|
+
*/
|
|
895
|
+
function decodeWriteBytes(bytes, encodingHint) {
|
|
896
|
+
const encoding = encodingHint?.trim().toLowerCase().replace(/[_ ]/g, "-");
|
|
897
|
+
if (encoding && encoding !== "utf-8" && encoding !== "utf8") {
|
|
898
|
+
try {
|
|
899
|
+
return new TextDecoder(encoding, { fatal: false }).decode(bytes);
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
trace(`write: unsupported encoding_hint ${JSON.stringify(encodingHint)} — decoding as utf-8`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return new TextDecoder("utf-8").decode(bytes);
|
|
906
|
+
}
|
|
784
907
|
function num(v) {
|
|
785
908
|
if (typeof v === "number" && Number.isFinite(v))
|
|
786
909
|
return v;
|
|
@@ -976,7 +1099,7 @@ export function buildExecClientMessages(input) {
|
|
|
976
1099
|
id: input.execId,
|
|
977
1100
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
978
1101
|
};
|
|
979
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome);
|
|
1102
|
+
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot);
|
|
980
1103
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
981
1104
|
exec_client_message: clientMsg,
|
|
982
1105
|
}));
|
|
@@ -1063,7 +1186,12 @@ export function unwrapReadOutput(output) {
|
|
|
1063
1186
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
1064
1187
|
* server accepts (verified against agent.v1 wire captures).
|
|
1065
1188
|
*/
|
|
1066
|
-
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome) {
|
|
1189
|
+
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
1190
|
+
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
1191
|
+
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
1192
|
+
const resultRoot = typeof workspaceRoot === "string" && workspaceRoot.trim()
|
|
1193
|
+
? path.resolve(workspaceRoot)
|
|
1194
|
+
: undefined;
|
|
1067
1195
|
switch (resultField) {
|
|
1068
1196
|
case "read_result": {
|
|
1069
1197
|
const readPath = str(resultMetadata?.path) ?? extractPathTag(output) ?? "";
|
|
@@ -1071,16 +1199,22 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1071
1199
|
return { error: { path: readPath, error } };
|
|
1072
1200
|
// Strip opencode's <path>/<content> envelope so Cursor's model receives
|
|
1073
1201
|
// raw file content and can't echo the wrapper into subsequent writes.
|
|
1074
|
-
//
|
|
1202
|
+
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1203
|
+
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1204
|
+
// native channel, so this is the path that matters in practice.
|
|
1075
1205
|
const content = unwrapReadOutput(output);
|
|
1076
1206
|
const statPath = extractPathTag(output) ?? readPath;
|
|
1077
1207
|
const outputMetadata = parseOpenCodeReadMetadata(output);
|
|
1078
1208
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1079
1209
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1210
|
+
// `truncated` alone is not enough: it is set here, and models still assert
|
|
1211
|
+
// the partial content is the whole file. Cursor's own executor puts the
|
|
1212
|
+
// limit marker in the output text for the same reason, so append one.
|
|
1213
|
+
const notice = readTruncationNotice(output, resultMetadata);
|
|
1080
1214
|
return {
|
|
1081
1215
|
success: {
|
|
1082
1216
|
path: readPath,
|
|
1083
|
-
content,
|
|
1217
|
+
content: notice ? `${content}\n\n${notice}` : content,
|
|
1084
1218
|
total_lines: totalLines,
|
|
1085
1219
|
file_size: readFileSize(statPath),
|
|
1086
1220
|
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
@@ -1094,7 +1228,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1094
1228
|
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
1095
1229
|
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
1096
1230
|
const files = extractPathLines(output);
|
|
1097
|
-
const cwd =
|
|
1231
|
+
const cwd = resultRoot ?? "";
|
|
1098
1232
|
return {
|
|
1099
1233
|
success: {
|
|
1100
1234
|
pattern: "",
|
|
@@ -1112,25 +1246,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1112
1246
|
},
|
|
1113
1247
|
};
|
|
1114
1248
|
}
|
|
1115
|
-
case "write_result":
|
|
1249
|
+
case "write_result": {
|
|
1250
|
+
// `apply_patch` output carries no <path> tag, so prefer the path recorded
|
|
1251
|
+
// when the request was remapped away from `write`.
|
|
1252
|
+
const remappedPath = str(resultMetadata?.path);
|
|
1116
1253
|
if (error)
|
|
1117
|
-
return { error: { path: "", error } };
|
|
1254
|
+
return { error: { path: remappedPath ?? "", error } };
|
|
1118
1255
|
return {
|
|
1119
1256
|
success: {
|
|
1120
|
-
path: extractPathTag(output) ?? "",
|
|
1257
|
+
path: remappedPath ?? extractPathTag(output) ?? "",
|
|
1121
1258
|
lines_created: countLines(output),
|
|
1122
1259
|
file_size: output.length,
|
|
1123
1260
|
},
|
|
1124
1261
|
};
|
|
1262
|
+
}
|
|
1125
1263
|
case "pi_write_result":
|
|
1126
1264
|
// PiWriteExecSuccess is just { output }; error is { error }.
|
|
1127
1265
|
if (error)
|
|
1128
1266
|
return { error: { error } };
|
|
1129
1267
|
return { success: { output: output || "Wrote file successfully." } };
|
|
1130
|
-
case "pi_read_result":
|
|
1268
|
+
case "pi_read_result": {
|
|
1131
1269
|
if (error)
|
|
1132
1270
|
return { error: { error } };
|
|
1133
|
-
|
|
1271
|
+
// Pi results carry truncation structurally (PiReadExecSuccess field 2),
|
|
1272
|
+
// which is how Cursor's own executors report a capped payload.
|
|
1273
|
+
const content = unwrapReadOutput(output);
|
|
1274
|
+
const truncation = readTruncationMessage(output, content);
|
|
1275
|
+
return { success: { output: content, ...(truncation ? { truncation } : {}) } };
|
|
1276
|
+
}
|
|
1134
1277
|
case "pi_bash_result":
|
|
1135
1278
|
case "pi_edit_result":
|
|
1136
1279
|
case "pi_grep_result":
|
|
@@ -1184,7 +1327,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1184
1327
|
case "ls_result": {
|
|
1185
1328
|
if (error)
|
|
1186
1329
|
return { error: { path: "", error } };
|
|
1187
|
-
const rootPath =
|
|
1330
|
+
const rootPath = resultRoot ?? "";
|
|
1188
1331
|
const entries = extractPathLines(output);
|
|
1189
1332
|
return {
|
|
1190
1333
|
success: {
|
|
@@ -1199,21 +1342,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1199
1342
|
},
|
|
1200
1343
|
};
|
|
1201
1344
|
}
|
|
1202
|
-
case "mcp_result":
|
|
1345
|
+
case "mcp_result": {
|
|
1203
1346
|
if (error)
|
|
1204
1347
|
return { error: { error } };
|
|
1205
1348
|
// opencode built-ins (read/write/grep/…) are advertised as MCP tools, so
|
|
1206
1349
|
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
1207
1350
|
// "read" so a non-read MCP tool whose output merely contains a
|
|
1208
1351
|
// "<content>"-like block is never rewritten.
|
|
1352
|
+
if (toolName !== "read") {
|
|
1353
|
+
return { success: { content: [{ text: { text: output } }], is_error: false } };
|
|
1354
|
+
}
|
|
1355
|
+
// Carry the truncation notice as its own content item: the file content
|
|
1356
|
+
// item stays byte-exact, so it can still never be echoed into a write.
|
|
1357
|
+
const notice = readTruncationNotice(output);
|
|
1209
1358
|
return {
|
|
1210
1359
|
success: {
|
|
1211
1360
|
content: [
|
|
1212
|
-
{ text: { text:
|
|
1361
|
+
{ text: { text: unwrapReadOutput(output) } },
|
|
1362
|
+
...(notice ? [{ text: { text: notice } }] : []),
|
|
1213
1363
|
],
|
|
1214
1364
|
is_error: false,
|
|
1215
1365
|
},
|
|
1216
1366
|
};
|
|
1367
|
+
}
|
|
1217
1368
|
case "subagent_result": {
|
|
1218
1369
|
const task = parseOpenCodeTaskOutput(output);
|
|
1219
1370
|
if (error || task.state === "error") {
|
|
@@ -1265,22 +1416,114 @@ function extractPathTag(output) {
|
|
|
1265
1416
|
const m = output.match(/<path>([^<]+)<\/path>/);
|
|
1266
1417
|
return m?.[1];
|
|
1267
1418
|
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Isolate opencode's read footer: the last blank-line-separated block before
|
|
1421
|
+
* the closing `</content>`. Scanning the whole envelope would let a file that
|
|
1422
|
+
* merely *quotes* a footer (this repository's own docs and tests do) pass for a
|
|
1423
|
+
* truncated read. The body can never contain a blank line — every source line
|
|
1424
|
+
* is rendered as `N: …`, so even an empty line keeps its `N: ` prefix.
|
|
1425
|
+
*
|
|
1426
|
+
* Falls back to the full string when the envelope is absent, preserving
|
|
1427
|
+
* behavior for non-standard read output.
|
|
1428
|
+
*/
|
|
1429
|
+
function readEnvelopeFooter(output) {
|
|
1430
|
+
const close = output.lastIndexOf("\n</content>");
|
|
1431
|
+
if (close === -1)
|
|
1432
|
+
return output;
|
|
1433
|
+
const start = output.lastIndexOf("\n\n", close);
|
|
1434
|
+
if (start === -1)
|
|
1435
|
+
return output;
|
|
1436
|
+
return output.slice(start + 2, close);
|
|
1437
|
+
}
|
|
1268
1438
|
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
1269
1439
|
function parseOpenCodeReadMetadata(output) {
|
|
1270
|
-
const
|
|
1440
|
+
const footer = readEnvelopeFooter(output);
|
|
1441
|
+
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
|
|
1271
1442
|
if (showing) {
|
|
1272
1443
|
return {
|
|
1273
1444
|
startLine: Number(showing[1]),
|
|
1274
1445
|
endLine: Number(showing[2]),
|
|
1275
1446
|
...(showing[3] ? { totalLines: Number(showing[3]) } : {}),
|
|
1276
|
-
outputCapped:
|
|
1447
|
+
outputCapped: footer.includes("(Output capped at "),
|
|
1277
1448
|
};
|
|
1278
1449
|
}
|
|
1279
|
-
const complete = /\(End of file - total (\d+) lines?\)/.exec(
|
|
1450
|
+
const complete = /\(End of file - total (\d+) lines?\)/.exec(footer);
|
|
1280
1451
|
if (complete)
|
|
1281
1452
|
return { totalLines: Number(complete[1]) };
|
|
1282
1453
|
return {};
|
|
1283
1454
|
}
|
|
1455
|
+
/**
|
|
1456
|
+
* OpenCode's `read` caps output at 50 KB (`tool/read.ts` MAX_BYTES = 50 * 1024)
|
|
1457
|
+
* and cuts on a whole-line boundary, then appends
|
|
1458
|
+
* "(Output capped at 50 KB. Showing lines X-Y. Use offset=N to continue.)".
|
|
1459
|
+
*
|
|
1460
|
+
* `unwrapReadOutput` strips that footer along with the envelope, deliberately:
|
|
1461
|
+
* Cursor's model echoes whatever it is handed straight back into the next
|
|
1462
|
+
* write, so anything left in the content stream can end up written into the
|
|
1463
|
+
* file. But dropping the notice with no replacement is worse — the model then
|
|
1464
|
+
* believes a capped read is the complete file, rewrites it from what it has,
|
|
1465
|
+
* and everything past the cap is destroyed.
|
|
1466
|
+
*
|
|
1467
|
+
* Cursor's own CLI never truncates silently. Its local executor annotates every
|
|
1468
|
+
* capped payload ("50KB limit reached", "[Showing last …KB of line N (50KB
|
|
1469
|
+
* limit). Full output: …]") and returns structured truncation metadata. Mirror
|
|
1470
|
+
* that, keeping the signal out of the file content itself so it still cannot be
|
|
1471
|
+
* echoed into a write.
|
|
1472
|
+
*/
|
|
1473
|
+
function readTruncationSummary(output) {
|
|
1474
|
+
const meta = parseOpenCodeReadMetadata(output);
|
|
1475
|
+
if (meta.startLine === undefined || meta.endLine === undefined)
|
|
1476
|
+
return undefined;
|
|
1477
|
+
// A complete read reports "(End of file …)" and never reaches this shape.
|
|
1478
|
+
if (meta.totalLines !== undefined && meta.endLine >= meta.totalLines && !meta.outputCapped)
|
|
1479
|
+
return undefined;
|
|
1480
|
+
return {
|
|
1481
|
+
startLine: meta.startLine,
|
|
1482
|
+
endLine: meta.endLine,
|
|
1483
|
+
nextOffset: meta.endLine + 1,
|
|
1484
|
+
capped: meta.outputCapped === true,
|
|
1485
|
+
...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
|
|
1486
|
+
};
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Model-visible replacement for the stripped footer.
|
|
1490
|
+
*
|
|
1491
|
+
* Only for reads the model did not ask to bound. A caller that passed an
|
|
1492
|
+
* explicit offset/limit already knows it asked for a slice — warning there
|
|
1493
|
+
* would cry wolf on every deliberate paged read.
|
|
1494
|
+
*/
|
|
1495
|
+
function readTruncationNotice(output, resultMetadata) {
|
|
1496
|
+
const summary = readTruncationSummary(output);
|
|
1497
|
+
if (!summary)
|
|
1498
|
+
return undefined;
|
|
1499
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
1500
|
+
if (rangeRequested && !summary.capped)
|
|
1501
|
+
return undefined;
|
|
1502
|
+
const range = summary.totalLines !== undefined
|
|
1503
|
+
? `lines ${summary.startLine}-${summary.endLine} of ${summary.totalLines}`
|
|
1504
|
+
: `lines ${summary.startLine}-${summary.endLine}`;
|
|
1505
|
+
return (`[Partial read: the content above is ${range}` +
|
|
1506
|
+
(summary.capped ? ", capped at the host's 50 KB output limit" : "") +
|
|
1507
|
+
`. It is NOT the complete file. Continue with offset=${summary.nextOffset} before ` +
|
|
1508
|
+
`acting on the whole file; writing the content above back would delete everything ` +
|
|
1509
|
+
`after line ${summary.endLine}.]`);
|
|
1510
|
+
}
|
|
1511
|
+
/** agent.v1.PiTruncation for a capped OpenCode read. */
|
|
1512
|
+
function readTruncationMessage(output, content) {
|
|
1513
|
+
const summary = readTruncationSummary(output);
|
|
1514
|
+
if (!summary)
|
|
1515
|
+
return undefined;
|
|
1516
|
+
return {
|
|
1517
|
+
truncated: true,
|
|
1518
|
+
truncated_by: summary.capped ? "bytes" : "lines",
|
|
1519
|
+
...(summary.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
|
|
1520
|
+
output_lines: Math.max(0, summary.endLine - summary.startLine + 1),
|
|
1521
|
+
output_bytes: Buffer.byteLength(content, "utf8"),
|
|
1522
|
+
...(summary.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
|
|
1523
|
+
};
|
|
1524
|
+
}
|
|
1525
|
+
/** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
|
|
1526
|
+
const OPENCODE_READ_MAX_BYTES = 50 * 1024;
|
|
1284
1527
|
function readRangeApplied(resultMetadata, totalLines) {
|
|
1285
1528
|
const offset = num(resultMetadata?.offset);
|
|
1286
1529
|
const limit = num(resultMetadata?.limit);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped workspace directory for the OpenCode 2.0 plugin.
|
|
3
|
+
*
|
|
4
|
+
* The classic plugin learns the project directory from `input.directory`,
|
|
5
|
+
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
|
+
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
|
+
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
+
* The only place the 2.0 runtime names a session's actual directory is
|
|
9
|
+
* `SessionInfo.location.directory`, reachable from `session.hook("context")`'s
|
|
10
|
+
* `sessionID` via `ctx.session.get()`. Record it there and let the provider
|
|
11
|
+
* read it back by session id, same mechanism as `compaction-marker.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function markSessionDirectory(sessionID: string, directory: string | undefined): void;
|
|
16
|
+
export declare function getSessionDirectory(sessionID: string | undefined): string | undefined;
|
|
17
|
+
export declare function clearSessionDirectories(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped workspace directory for the OpenCode 2.0 plugin.
|
|
3
|
+
*
|
|
4
|
+
* The classic plugin learns the project directory from `input.directory`,
|
|
5
|
+
* supplied once per invocation by the 1.x host. OpenCode 2.0 runs a single
|
|
6
|
+
* daemon across many projects, so `process.cwd()` captured at sdk-creation
|
|
7
|
+
* time is wrong for any session but the one open when the daemon started.
|
|
8
|
+
* The only place the 2.0 runtime names a session's actual directory is
|
|
9
|
+
* `SessionInfo.location.directory`, reachable from `session.hook("context")`'s
|
|
10
|
+
* `sessionID` via `ctx.session.get()`. Record it there and let the provider
|
|
11
|
+
* read it back by session id, same mechanism as `compaction-marker.ts`.
|
|
12
|
+
*
|
|
13
|
+
* Bounded so a long-lived server cannot accumulate ids for dead sessions.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_TRACKED_SESSIONS = 256;
|
|
16
|
+
const sessionDirectories = new Map();
|
|
17
|
+
export function markSessionDirectory(sessionID, directory) {
|
|
18
|
+
if (!sessionID || !directory)
|
|
19
|
+
return;
|
|
20
|
+
// Re-insert to keep insertion order meaningful for the eviction below.
|
|
21
|
+
sessionDirectories.delete(sessionID);
|
|
22
|
+
sessionDirectories.set(sessionID, directory);
|
|
23
|
+
while (sessionDirectories.size > MAX_TRACKED_SESSIONS) {
|
|
24
|
+
const oldest = sessionDirectories.keys().next().value;
|
|
25
|
+
if (oldest === undefined)
|
|
26
|
+
break;
|
|
27
|
+
sessionDirectories.delete(oldest);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function getSessionDirectory(sessionID) {
|
|
31
|
+
return typeof sessionID === "string" ? sessionDirectories.get(sessionID) : undefined;
|
|
32
|
+
}
|
|
33
|
+
export function clearSessionDirectories() {
|
|
34
|
+
sessionDirectories.clear();
|
|
35
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export type PendingExec = {
|
|
|
50
50
|
*/
|
|
51
51
|
bridged?: boolean;
|
|
52
52
|
};
|
|
53
|
-
export type ContinuationTerminalReason = "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed";
|
|
53
|
+
export type ContinuationTerminalReason = "hard-cap-expired" | "remote-clean-close" | "remote-error" | "result-write-failed" | "ambiguous-partial-write" | "heartbeat-write-failed" | "reply-write-failed" | "process-disposed" | "superseded-by-new-run" | "open-session-cap-exceeded";
|
|
54
54
|
export type SessionCloseReason = ContinuationTerminalReason | "ordinary-cleanup" | "turn-ended" | "initial-write-failed";
|
|
55
55
|
export type ContinuationClaim = {
|
|
56
56
|
session: CursorSession;
|
|
@@ -140,6 +140,7 @@ export type CursorSession = {
|
|
|
140
140
|
outputTokens: number;
|
|
141
141
|
cacheRead: number;
|
|
142
142
|
cacheWrite: number;
|
|
143
|
+
reasoningTokens: number;
|
|
143
144
|
};
|
|
144
145
|
/**
|
|
145
146
|
* True while a doStream pull() is actively reading this session's frames.
|
|
@@ -171,10 +172,12 @@ type SessionManagerOptions = {
|
|
|
171
172
|
activitySource?: SessionActivitySource;
|
|
172
173
|
tombstoneTtlMs?: number;
|
|
173
174
|
tombstoneLimit?: number;
|
|
175
|
+
maxOpenSessions?: number;
|
|
174
176
|
};
|
|
175
177
|
export declare class SessionManager {
|
|
176
178
|
private byExecId;
|
|
177
179
|
private sessions;
|
|
180
|
+
private byOpenCodeSessionId;
|
|
178
181
|
private tombstones;
|
|
179
182
|
private readonly now;
|
|
180
183
|
private readonly setTimer;
|
|
@@ -182,8 +185,17 @@ export declare class SessionManager {
|
|
|
182
185
|
private readonly activitySource;
|
|
183
186
|
private readonly tombstoneTtlMs;
|
|
184
187
|
private readonly tombstoneLimit;
|
|
188
|
+
private readonly maxOpenSessions;
|
|
185
189
|
constructor(options?: SessionManagerOptions);
|
|
186
190
|
registerSession(session: CursorSession): void;
|
|
191
|
+
private isPumping;
|
|
192
|
+
/**
|
|
193
|
+
* Force-close the oldest idle open session(s) once registration exceeds the
|
|
194
|
+
* cap. Never closes a session with a live pull() mid-await (see the same
|
|
195
|
+
* guard in the supersede check above) — an unclosable backlog of genuinely
|
|
196
|
+
* active sessions just means the cap can't be enforced until one finishes.
|
|
197
|
+
*/
|
|
198
|
+
private enforceOpenSessionCap;
|
|
187
199
|
recordSemanticProgress(session: CursorSession, at?: number): void;
|
|
188
200
|
recordHeartbeatWrite(session: CursorSession): void;
|
|
189
201
|
/** Register that `session` is awaiting a result for `execId`. */
|
package/dist/session.js
CHANGED
|
@@ -9,6 +9,11 @@ export const DEFAULT_CONTINUATION_POLICY = {
|
|
|
9
9
|
const MAX_TIMER_MS = 2_147_483_647;
|
|
10
10
|
const DEFAULT_TOMBSTONE_TTL_MS = 15 * 60_000;
|
|
11
11
|
const DEFAULT_TOMBSTONE_LIMIT = 1_024;
|
|
12
|
+
// Well below Cursor's server-side concurrent-Run ceiling per HTTP/2 connection,
|
|
13
|
+
// so a caller that keeps abandoning Runs instead of delivering continuations
|
|
14
|
+
// gets corrected here instead of piling up open streams until Cursor's server
|
|
15
|
+
// force-closes the whole shared connection.
|
|
16
|
+
const DEFAULT_MAX_OPEN_SESSIONS = 24;
|
|
12
17
|
function positiveInteger(name, value, fallback) {
|
|
13
18
|
const resolved = value === undefined ? fallback : value;
|
|
14
19
|
if (typeof resolved !== "number" ||
|
|
@@ -50,6 +55,10 @@ export class SessionManager {
|
|
|
50
55
|
// counters per stream) coexist instead of overwriting each other.
|
|
51
56
|
byExecId = new Map();
|
|
52
57
|
sessions = new Set();
|
|
58
|
+
// Most recently registered open session per OpenCode session id. A second
|
|
59
|
+
// registration for the same id means the caller started a fresh Run instead
|
|
60
|
+
// of continuing the held-open one — the prior entry is stale and superseded.
|
|
61
|
+
byOpenCodeSessionId = new Map();
|
|
53
62
|
tombstones = new Map();
|
|
54
63
|
now;
|
|
55
64
|
setTimer;
|
|
@@ -57,6 +66,7 @@ export class SessionManager {
|
|
|
57
66
|
activitySource;
|
|
58
67
|
tombstoneTtlMs;
|
|
59
68
|
tombstoneLimit;
|
|
69
|
+
maxOpenSessions;
|
|
60
70
|
constructor(options = {}) {
|
|
61
71
|
this.now = options.now ?? Date.now;
|
|
62
72
|
this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
@@ -64,6 +74,7 @@ export class SessionManager {
|
|
|
64
74
|
this.activitySource = options.activitySource ?? sessionActivity;
|
|
65
75
|
this.tombstoneTtlMs = positiveInteger("tombstoneTtlMs", options.tombstoneTtlMs, DEFAULT_TOMBSTONE_TTL_MS);
|
|
66
76
|
this.tombstoneLimit = positiveInteger("tombstoneLimit", options.tombstoneLimit, DEFAULT_TOMBSTONE_LIMIT);
|
|
77
|
+
this.maxOpenSessions = positiveInteger("maxOpenSessions", options.maxOpenSessions, DEFAULT_MAX_OPEN_SESSIONS);
|
|
67
78
|
}
|
|
68
79
|
registerSession(session) {
|
|
69
80
|
if (session.closed)
|
|
@@ -84,12 +95,66 @@ export class SessionManager {
|
|
|
84
95
|
session.lastHeartbeatWriteAt ??= this.now();
|
|
85
96
|
session.semanticDeadlineAt ??= this.now() + session.policy.semanticIdleMs;
|
|
86
97
|
this.sessions.add(session);
|
|
98
|
+
if (session.openCodeSessionId) {
|
|
99
|
+
const prior = this.byOpenCodeSessionId.get(session.openCodeSessionId);
|
|
100
|
+
if (prior && prior !== session && !prior.closed) {
|
|
101
|
+
if (this.isPumping(prior)) {
|
|
102
|
+
// A live pull() is mid-await on this stream (nextFrameWithSemanticDeadline
|
|
103
|
+
// or the raw frame iterator). Forcing close() here would reject that await
|
|
104
|
+
// and propagate a real error to whatever still holds the ReadableStream —
|
|
105
|
+
// exactly the kind of self-inflicted failure this cap is meant to prevent.
|
|
106
|
+
// Let it finish or expire on its own (hard deadline / heartbeat failure).
|
|
107
|
+
trace(`sessionManager.registerSession: openCodeSessionId=${session.openCodeSessionId} ` +
|
|
108
|
+
`has a still-pumping prior session ${prior.sessionId} (pending=${prior.pending.size}) ` +
|
|
109
|
+
`— leaving it to finish or expire naturally instead of interrupting it`);
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
trace(`sessionManager.registerSession: superseding stale session ${prior.sessionId} ` +
|
|
113
|
+
`(openCodeSessionId=${session.openCodeSessionId}, pending=${prior.pending.size}) ` +
|
|
114
|
+
`with new session ${session.sessionId}`);
|
|
115
|
+
this.close(prior, "superseded-by-new-run");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
this.byOpenCodeSessionId.set(session.openCodeSessionId, session);
|
|
119
|
+
}
|
|
120
|
+
this.enforceOpenSessionCap(session);
|
|
87
121
|
const unsubscribe = session.stream.onTerminal?.((event) => this.onStreamTerminal(session, event)) ?? (() => { });
|
|
88
122
|
if (session.closed)
|
|
89
123
|
unsubscribe();
|
|
90
124
|
else
|
|
91
125
|
session.terminalUnsubscribe = unsubscribe;
|
|
92
126
|
}
|
|
127
|
+
isPumping(session) {
|
|
128
|
+
return session.pumpOwner != null || session.pumpActive;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Force-close the oldest idle open session(s) once registration exceeds the
|
|
132
|
+
* cap. Never closes a session with a live pull() mid-await (see the same
|
|
133
|
+
* guard in the supersede check above) — an unclosable backlog of genuinely
|
|
134
|
+
* active sessions just means the cap can't be enforced until one finishes.
|
|
135
|
+
*/
|
|
136
|
+
enforceOpenSessionCap(justRegistered) {
|
|
137
|
+
while (this.sessions.size > this.maxOpenSessions) {
|
|
138
|
+
let oldest;
|
|
139
|
+
for (const candidate of this.sessions) {
|
|
140
|
+
if (candidate === justRegistered || this.isPumping(candidate))
|
|
141
|
+
continue;
|
|
142
|
+
if (!oldest || candidate.createdAt < oldest.createdAt)
|
|
143
|
+
oldest = candidate;
|
|
144
|
+
}
|
|
145
|
+
if (!oldest) {
|
|
146
|
+
trace(`sessionManager.registerSession: open session cap exceeded ` +
|
|
147
|
+
`(${this.sessions.size} > ${this.maxOpenSessions}) but no idle session is ` +
|
|
148
|
+
`available to close — all remaining sessions are actively pumping`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
trace(`sessionManager.registerSession: open session cap exceeded ` +
|
|
152
|
+
`(${this.sessions.size} > ${this.maxOpenSessions}), force-closing oldest idle session ` +
|
|
153
|
+
`${oldest.sessionId} (openCodeSessionId=${oldest.openCodeSessionId ?? "-"}, ` +
|
|
154
|
+
`ageMs=${this.now() - oldest.createdAt}, pending=${oldest.pending.size})`);
|
|
155
|
+
this.close(oldest, "open-session-cap-exceeded");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
93
158
|
recordSemanticProgress(session, at) {
|
|
94
159
|
if (session.closed)
|
|
95
160
|
return;
|
|
@@ -295,6 +360,10 @@ export class SessionManager {
|
|
|
295
360
|
session.displayToolCalls?.clear();
|
|
296
361
|
session.blobs?.clear();
|
|
297
362
|
this.sessions.delete(session);
|
|
363
|
+
if (session.openCodeSessionId &&
|
|
364
|
+
this.byOpenCodeSessionId.get(session.openCodeSessionId) === session) {
|
|
365
|
+
this.byOpenCodeSessionId.delete(session.openCodeSessionId);
|
|
366
|
+
}
|
|
298
367
|
try {
|
|
299
368
|
session.stream.destroy();
|
|
300
369
|
}
|
|
@@ -311,7 +380,7 @@ export class SessionManager {
|
|
|
311
380
|
closeUnlessPending(session) {
|
|
312
381
|
if (session.closed)
|
|
313
382
|
return true;
|
|
314
|
-
const pumpActive =
|
|
383
|
+
const pumpActive = this.isPumping(session);
|
|
315
384
|
if (session.pending.size > 0 || pumpActive) {
|
|
316
385
|
trace(`sessionManager.closeUnlessPending: KEEP open pendingCount=${session.pending.size} pumpActive=${pumpActive}`);
|
|
317
386
|
return false;
|
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. */
|