cursor-opencode-provider 0.8.0 → 0.8.3
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/CHANGELOG.md +174 -0
- package/README.md +230 -44
- package/SECURITY.md +60 -12
- package/dist/activity.d.ts +73 -1
- package/dist/activity.js +231 -5
- package/dist/agent-url.js +37 -9
- package/dist/auth-renewal.d.ts +1 -1
- package/dist/auth-renewal.js +58 -26
- package/dist/billing.d.ts +105 -0
- package/dist/billing.js +212 -0
- package/dist/classic-tool-results.d.ts +1 -0
- package/dist/classic-tool-results.js +42 -0
- package/dist/context/build.d.ts +23 -4
- package/dist/context/build.js +65 -10
- package/dist/context/dynamic-catalog.js +6 -1
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/frozen.js +6 -2
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/paths.d.ts +45 -18
- package/dist/context/paths.js +72 -22
- package/dist/context/skills-bridge.d.ts +22 -0
- package/dist/context/skills-bridge.js +20 -0
- package/dist/context/skills.d.ts +34 -0
- package/dist/context/skills.js +211 -0
- package/dist/debug.js +13 -2
- package/dist/early-tool-rows.d.ts +60 -0
- package/dist/early-tool-rows.js +96 -0
- package/dist/errors.d.ts +49 -7
- package/dist/errors.js +163 -8
- package/dist/host-agent-mode.d.ts +53 -4
- package/dist/host-agent-mode.js +117 -13
- package/dist/host-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-plan-file.d.ts +28 -0
- package/dist/host-plan-file.js +76 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.d.ts +18 -0
- package/dist/image-input.js +122 -30
- package/dist/image-save.d.ts +10 -2
- package/dist/image-save.js +33 -21
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +149 -30
- package/dist/language-model.js +3127 -673
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +12 -1
- package/dist/model-config.js +78 -30
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/catalog.d.ts +1 -0
- package/dist/opencode2/catalog.js +2 -0
- package/dist/opencode2/image-save-tool.d.ts +8 -0
- package/dist/opencode2/image-save-tool.js +84 -0
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/opencode2/types.d.ts +15 -0
- package/dist/parallel-step.d.ts +65 -0
- package/dist/parallel-step.js +102 -0
- package/dist/plugin-opencode2.js +233 -65
- package/dist/plugin.js +97 -23
- package/dist/pricing-data.d.ts +40 -3
- package/dist/pricing-data.js +40 -3
- package/dist/pricing.d.ts +1 -1
- package/dist/pricing.js +7 -4
- package/dist/process-shared.d.ts +14 -0
- package/dist/process-shared.js +18 -0
- package/dist/protocol/apply-patch.d.ts +25 -13
- package/dist/protocol/apply-patch.js +79 -30
- package/dist/protocol/ask-question.d.ts +12 -1
- package/dist/protocol/ask-question.js +77 -3
- package/dist/protocol/blob-store.d.ts +5 -1
- package/dist/protocol/blob-store.js +10 -6
- package/dist/protocol/client-version.js +6 -4
- package/dist/protocol/conversation-cache-header.d.ts +23 -0
- package/dist/protocol/conversation-cache-header.js +149 -0
- package/dist/protocol/conversation-persistence.d.ts +27 -1
- package/dist/protocol/conversation-persistence.js +628 -126
- package/dist/protocol/conversation-state.d.ts +8 -1
- package/dist/protocol/conversation-state.js +28 -7
- package/dist/protocol/create-plan.d.ts +47 -25
- package/dist/protocol/create-plan.js +33 -23
- package/dist/protocol/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/interactions.d.ts +26 -17
- package/dist/protocol/interactions.js +80 -31
- package/dist/protocol/messages.js +164 -21
- package/dist/protocol/request.d.ts +40 -19
- package/dist/protocol/request.js +55 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/switch-mode.d.ts +66 -27
- package/dist/protocol/switch-mode.js +147 -73
- package/dist/protocol/token-details.d.ts +2 -0
- package/dist/protocol/token-details.js +11 -1
- package/dist/protocol/tool-call-bridge.d.ts +6 -0
- package/dist/protocol/tool-call-bridge.js +24 -0
- package/dist/protocol/tools.d.ts +90 -9
- package/dist/protocol/tools.js +543 -151
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +22 -4
- package/dist/replay-safety.js +86 -27
- package/dist/retry-budget.d.ts +21 -0
- package/dist/retry-budget.js +94 -0
- package/dist/runtime-files.d.ts +23 -0
- package/dist/runtime-files.js +581 -0
- package/dist/session.d.ts +129 -10
- package/dist/session.js +198 -35
- package/dist/shared.d.ts +1 -1
- package/dist/shared.js +1 -1
- package/dist/shell-timeout.d.ts +54 -10
- package/dist/shell-timeout.js +362 -92
- package/dist/stream-write.d.ts +20 -0
- package/dist/stream-write.js +87 -0
- package/dist/terminal-runner.d.ts +15 -0
- package/dist/terminal-runner.js +528 -0
- package/dist/todo-store.d.ts +2 -0
- package/dist/todo-store.js +19 -0
- package/dist/transport/connect.d.ts +10 -1
- package/dist/transport/connect.js +124 -27
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +85 -34
- package/package.json +5 -3
- package/dist/plan-execution-kickoff.d.ts +0 -57
- package/dist/plan-execution-kickoff.js +0 -186
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
package/dist/protocol/tools.js
CHANGED
|
@@ -1,18 +1,44 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { encodeMessage } from "./messages.js";
|
|
4
|
+
import { encodeMessage, getMessageTypes } from "./messages.js";
|
|
5
5
|
import { encodeJsonAsValue, decodeStructEntriesToJson, readAllFields } from "./struct.js";
|
|
6
6
|
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, FORCE_BACKGROUND_STATUS_ERROR, } from "./exec-variants.js";
|
|
10
|
-
import {
|
|
10
|
+
import { buildAddFilePatch, buildUpdateFilePatch, hostPatchTool, isHostPatchTool, planSubstringEdit, } from "./apply-patch.js";
|
|
11
11
|
import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-timeout.js";
|
|
12
|
+
import { normalizeOpencodeQuestionArgs } from "./ask-question.js";
|
|
13
|
+
import { normalizeOpencodeTodoArgs } from "../todo-store.js";
|
|
12
14
|
// Exec variant field number whose reply is the server-initiated request_context
|
|
13
15
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
14
16
|
// field number for every exec variant, so this is also the result field.
|
|
15
17
|
export const REQUEST_CONTEXT_RESULT_FIELD = 10;
|
|
18
|
+
/** Check explicit required keys after normalization, before host execution. */
|
|
19
|
+
export function missingRequiredToolArguments(tool, args) {
|
|
20
|
+
const schema = tool?.inputSchema;
|
|
21
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
22
|
+
return [];
|
|
23
|
+
const required = schema.required;
|
|
24
|
+
if (!Array.isArray(required))
|
|
25
|
+
return [];
|
|
26
|
+
return required.filter((key) => typeof key === "string" && (!Object.hasOwn(args, key) || args[key] === undefined));
|
|
27
|
+
}
|
|
28
|
+
/** Canonical file/search tools must not silently ignore a misplaced shell command. */
|
|
29
|
+
export function misplacedShellCommand(tool, args) {
|
|
30
|
+
if (!tool || args.command === undefined)
|
|
31
|
+
return false;
|
|
32
|
+
if (!["read", "write", "edit", "grep", "glob", "ls"].includes(tool.name))
|
|
33
|
+
return false;
|
|
34
|
+
const schema = tool.inputSchema;
|
|
35
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
36
|
+
return false;
|
|
37
|
+
const properties = schema.properties;
|
|
38
|
+
if (!properties || typeof properties !== "object" || Array.isArray(properties))
|
|
39
|
+
return false;
|
|
40
|
+
return !Object.hasOwn(properties, "command");
|
|
41
|
+
}
|
|
16
42
|
export const OPENCODE_1_TOOL_DIALECT = {
|
|
17
43
|
filePathKey: "filePath",
|
|
18
44
|
shellTool: "bash",
|
|
@@ -88,7 +114,20 @@ export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL
|
|
|
88
114
|
? "id"
|
|
89
115
|
: defaultDialect.skillArgKey;
|
|
90
116
|
}
|
|
91
|
-
|
|
117
|
+
const shellSchema = tools.find((tool) => tool.name === shellTool)?.inputSchema;
|
|
118
|
+
const shellProps = jsonSchemaProperties(shellSchema);
|
|
119
|
+
const description = shellProps?.description;
|
|
120
|
+
const required = shellSchema && typeof shellSchema === "object" && !Array.isArray(shellSchema)
|
|
121
|
+
? shellSchema.required
|
|
122
|
+
: undefined;
|
|
123
|
+
return {
|
|
124
|
+
filePathKey, shellTool, skillArgKey,
|
|
125
|
+
...(description && typeof description === "object"
|
|
126
|
+
&& description.type === "string"
|
|
127
|
+
? { shellDescription: Array.isArray(required) && required.includes("description")
|
|
128
|
+
? "required" : "optional" }
|
|
129
|
+
: {}),
|
|
130
|
+
};
|
|
92
131
|
}
|
|
93
132
|
function assignHostFilePath(args, filePath, dialect) {
|
|
94
133
|
delete args.filePath;
|
|
@@ -628,6 +667,46 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
628
667
|
}
|
|
629
668
|
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
630
669
|
const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
670
|
+
export function cursorEditReadAnswer(absolutePath, workspaceRoot) {
|
|
671
|
+
try {
|
|
672
|
+
fs.lstatSync(absolutePath);
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
return error.code === "ENOENT" ? "empty" : "host";
|
|
676
|
+
}
|
|
677
|
+
// Do not bypass OpenCode's external-directory permission boundary.
|
|
678
|
+
let relative;
|
|
679
|
+
try {
|
|
680
|
+
relative = path.relative(fs.realpathSync(workspaceRoot), fs.realpathSync(absolutePath));
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return "external";
|
|
684
|
+
}
|
|
685
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
|
|
686
|
+
return "external";
|
|
687
|
+
try {
|
|
688
|
+
const stat = fs.statSync(absolutePath);
|
|
689
|
+
if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
|
|
690
|
+
return "host";
|
|
691
|
+
fs.closeSync(fs.openSync(absolutePath, "r"));
|
|
692
|
+
return "complete";
|
|
693
|
+
}
|
|
694
|
+
catch {
|
|
695
|
+
return "host";
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* The edit's private read becomes its own host `read` call. A read the edit
|
|
700
|
+
* handshake leaves to the host can still be answered by the provider's
|
|
701
|
+
* missing-target validation (a dangling symlink, a directory); that read
|
|
702
|
+
* never reaches the host.
|
|
703
|
+
*/
|
|
704
|
+
export function cursorEditReadReachesHost(absolutePath, workspaceRoot) {
|
|
705
|
+
const answer = cursorEditReadAnswer(absolutePath, workspaceRoot);
|
|
706
|
+
if (answer !== "external" && answer !== "host")
|
|
707
|
+
return false;
|
|
708
|
+
return classifyMissingReadTarget(absolutePath) === undefined;
|
|
709
|
+
}
|
|
631
710
|
/**
|
|
632
711
|
* Complete the private read inside Cursor's legacy edit transaction without
|
|
633
712
|
* routing it through OpenCode's user-facing `read` tool. The latter caps every
|
|
@@ -667,6 +746,60 @@ export function buildCompleteEditReadMessages(execId, sourcePath, resultPath = s
|
|
|
667
746
|
return undefined;
|
|
668
747
|
}
|
|
669
748
|
}
|
|
749
|
+
/** 1x1 transparent PNG: satisfies Cursor's binary-attachment check without file bytes. */
|
|
750
|
+
const TASK_ATTACHMENT_PLACEHOLDER = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", "base64");
|
|
751
|
+
/**
|
|
752
|
+
* Answer Cursor's read of a Task attachment without the file's bytes. Cursor
|
|
753
|
+
* puts them only in SubagentArgs.selected_context, which OpenCode's subagent
|
|
754
|
+
* tools cannot take, yet refuses the Task unless the read returns binary data.
|
|
755
|
+
* The path reaches the subagent prompt instead ({@link appendTaskAttachments})
|
|
756
|
+
* and the child reads it under its own permissions. A missing or non-regular
|
|
757
|
+
* target gets the same typed rejection as any native read.
|
|
758
|
+
*/
|
|
759
|
+
export function buildTaskAttachmentReadMessages(execId, sourcePath, resultPath = sourcePath) {
|
|
760
|
+
// Unlike an ordinary host read, this path has no later executor to surface
|
|
761
|
+
// stat errors. Never mistake EACCES/ELOOP (or invalid input) for a real file.
|
|
762
|
+
try {
|
|
763
|
+
const rejected = classifyReadTargetStat(sourcePath, fs.statSync(sourcePath));
|
|
764
|
+
if (rejected)
|
|
765
|
+
return buildReadRejectionMessages(execId, rejected);
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
const code = error.code;
|
|
769
|
+
const rejected = code === "ENOENT" || code === "ENOTDIR"
|
|
770
|
+
? { file_not_found: { path: sourcePath } }
|
|
771
|
+
: undefined;
|
|
772
|
+
return buildReadRejectionMessages(execId, rejected ?? {
|
|
773
|
+
error: { path: resultPath, error: error instanceof Error ? error.message : String(error) },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
return [
|
|
777
|
+
encodeMessage("AgentClientMessage", {
|
|
778
|
+
exec_client_message: {
|
|
779
|
+
id: execId,
|
|
780
|
+
local_execution_time_ms: 0,
|
|
781
|
+
read_result: {
|
|
782
|
+
success: {
|
|
783
|
+
path: resultPath,
|
|
784
|
+
data: TASK_ATTACHMENT_PLACEHOLDER,
|
|
785
|
+
total_lines: 0,
|
|
786
|
+
file_size: TASK_ATTACHMENT_PLACEHOLDER.length,
|
|
787
|
+
truncated: false,
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
}),
|
|
792
|
+
buildExecStreamClose(execId),
|
|
793
|
+
];
|
|
794
|
+
}
|
|
795
|
+
/** List Cursor's Task attachments at the end of the subagent prompt as absolute paths. */
|
|
796
|
+
export function appendTaskAttachments(parsed, attachments, workspaceRoot) {
|
|
797
|
+
if (parsed.resultField !== "subagent_result" || attachments.length === 0)
|
|
798
|
+
return;
|
|
799
|
+
const prompt = str(parsed.args.prompt) ?? "";
|
|
800
|
+
const paths = attachments.map((item) => `- ${JSON.stringify(resolveReadTargetPath(item, workspaceRoot))}`);
|
|
801
|
+
parsed.args.prompt = `${prompt}\n\nAttached files (open them with your file tools):\n${paths.join("\n")}`;
|
|
802
|
+
}
|
|
670
803
|
/** Return the number of non-overlapping occurrences, stopping after ambiguity. */
|
|
671
804
|
function replacementOccurrenceCount(source, needle) {
|
|
672
805
|
if (!needle)
|
|
@@ -739,6 +872,41 @@ function planWholeFileEdit(source, target) {
|
|
|
739
872
|
}
|
|
740
873
|
return result;
|
|
741
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Host tool a Cursor edit announcement will become, using the same file facts
|
|
877
|
+
* as `remapCorrelatedEditWriteForCatalog` and `remapEditToolsForCatalog`. An
|
|
878
|
+
* existing file becomes `edit`, else the patch tool. A missing file is a
|
|
879
|
+
* creation, which stays `write`, else becomes the patch tool's Add File; so
|
|
880
|
+
* does an empty file, which has no text for an `edit` to replace. A name is
|
|
881
|
+
* withheld when those remaps would refuse the file or not produce that tool:
|
|
882
|
+
* no workspace for a relative path, not a regular file, too large,
|
|
883
|
+
* unreadable, or `patch` with carriage returns.
|
|
884
|
+
*/
|
|
885
|
+
export function hostToolForCursorEdit(filePath, catalog, workspaceRoot) {
|
|
886
|
+
const patchTool = hostPatchTool(catalog);
|
|
887
|
+
if (!path.isAbsolute(filePath) && !workspaceRoot)
|
|
888
|
+
return undefined;
|
|
889
|
+
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
890
|
+
try {
|
|
891
|
+
const stat = fs.statSync(absolute);
|
|
892
|
+
if (!stat.isFile() || stat.size > MAX_EDIT_SOURCE_BYTES)
|
|
893
|
+
return undefined;
|
|
894
|
+
if (stat.size === 0)
|
|
895
|
+
return catalog.has("write") ? "write" : patchTool;
|
|
896
|
+
if (catalog.has("edit"))
|
|
897
|
+
return "edit";
|
|
898
|
+
if (!patchTool)
|
|
899
|
+
return undefined;
|
|
900
|
+
if (patchTool === "patch" && /\r(?:\n|$)/.test(fs.readFileSync(absolute, "utf8")))
|
|
901
|
+
return undefined;
|
|
902
|
+
return patchTool;
|
|
903
|
+
}
|
|
904
|
+
catch (error) {
|
|
905
|
+
if (error.code !== "ENOENT")
|
|
906
|
+
return undefined;
|
|
907
|
+
return catalog.has("write") ? "write" : patchTool;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
742
910
|
/**
|
|
743
911
|
* Preserve a legacy Cursor edit's intent when its internal executor follows
|
|
744
912
|
* `edit_tool_call` with a whole-file `write_args` request.
|
|
@@ -750,7 +918,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
750
918
|
if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
|
|
751
919
|
return false;
|
|
752
920
|
const advertised = new Set(advertisedToolNames);
|
|
753
|
-
if (!advertised.has("edit") && !advertised
|
|
921
|
+
if (!advertised.has("edit") && !hostPatchTool(advertised))
|
|
754
922
|
return false;
|
|
755
923
|
const filePath = opencodePathArg(parsed.args);
|
|
756
924
|
const content = stringValue(parsed.args.content);
|
|
@@ -789,93 +957,183 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
789
957
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
790
958
|
return true;
|
|
791
959
|
}
|
|
960
|
+
/** Preserve file endings for direct dynamic patch calls as well as native edits. */
|
|
961
|
+
function preserveDirectPatchLineEndings(parsed, workspaceRoot) {
|
|
962
|
+
const patchText = stringValue(parsed.args.patchText);
|
|
963
|
+
if (patchText === undefined)
|
|
964
|
+
return;
|
|
965
|
+
try {
|
|
966
|
+
if (parsed.toolName === "patch" && /\r(?:\n|$)/.test(patchText)) {
|
|
967
|
+
throw new Error("the patch tool strips carriage returns from line endings; this content cannot be preserved");
|
|
968
|
+
}
|
|
969
|
+
const lines = patchText.split("\n");
|
|
970
|
+
let crlf = false;
|
|
971
|
+
for (let index = 0; index < lines.length; index++) {
|
|
972
|
+
const line = lines[index];
|
|
973
|
+
if (line.startsWith("*** Update File:")) {
|
|
974
|
+
crlf = false;
|
|
975
|
+
const target = path.resolve(workspaceRoot ?? process.cwd(), line.slice("*** Update File:".length).trim());
|
|
976
|
+
let source;
|
|
977
|
+
try {
|
|
978
|
+
const stat = fs.statSync(target);
|
|
979
|
+
// Let the host report missing targets and wrong file kinds normally.
|
|
980
|
+
if (!stat.isFile())
|
|
981
|
+
continue;
|
|
982
|
+
if (stat.size > MAX_EDIT_SOURCE_BYTES) {
|
|
983
|
+
throw new Error("the target file is too large to inspect safely for patch newline preservation");
|
|
984
|
+
}
|
|
985
|
+
source = fs.readFileSync(target, "utf8");
|
|
986
|
+
}
|
|
987
|
+
catch (error) {
|
|
988
|
+
if (error.code)
|
|
989
|
+
continue;
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
if (source && !source.endsWith("\n")) {
|
|
993
|
+
throw new Error("the patch tool cannot safely update content without a final newline");
|
|
994
|
+
}
|
|
995
|
+
crlf = source.includes("\r\n");
|
|
996
|
+
if (!crlf)
|
|
997
|
+
continue;
|
|
998
|
+
if (parsed.toolName === "patch") {
|
|
999
|
+
throw new Error("the patch tool strips carriage returns from line endings; the source content cannot be preserved");
|
|
1000
|
+
}
|
|
1001
|
+
if (/(^|[^\r])\n/.test(source)) {
|
|
1002
|
+
throw new Error("direct patch context has mixed line endings; use a Cursor-native targeted edit to preserve them");
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
else if (line.startsWith("***") && !line.startsWith("*** Move to:")) {
|
|
1006
|
+
crlf = false;
|
|
1007
|
+
}
|
|
1008
|
+
else if (crlf && /^[ +\-]/.test(line)) {
|
|
1009
|
+
// Context is copied into the result by the host, so it needs the same
|
|
1010
|
+
// CRLF restoration as additions. File headers and Add File stay exact.
|
|
1011
|
+
lines[index] = `${line.replace(/\r$/, "")}\r`;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
parsed.args.patchText = lines.join("\n");
|
|
1015
|
+
}
|
|
1016
|
+
catch (error) {
|
|
1017
|
+
parsed.args = {};
|
|
1018
|
+
parsed.localError = `Cursor ${parsed.toolName} request cannot preserve file contents: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
792
1021
|
/**
|
|
793
|
-
* Express a native Cursor write/edit as
|
|
1022
|
+
* Express a native Cursor write/edit as a patch call when the host swapped the
|
|
794
1023
|
* edit-tool family out from under us.
|
|
795
1024
|
*
|
|
796
|
-
* OpenCode
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
* using its native write/edit
|
|
800
|
-
*
|
|
801
|
-
*
|
|
1025
|
+
* Both OpenCode majors remove `edit` and `write` from the catalog for GPT
|
|
1026
|
+
* models and advertise a patch tool in their place (`apply_patch` on 1.x,
|
|
1027
|
+
* `patch` on 2.0) — see the module comment in `./apply-patch.ts` for the
|
|
1028
|
+
* upstream reasoning and citations. Cursor keeps using its native write/edit
|
|
1029
|
+
* exec channel regardless, so without this the request is refused as an
|
|
1030
|
+
* unavailable tool.
|
|
802
1031
|
*
|
|
803
1032
|
* Keyed purely off the advertised set: this is inert whenever the host offers
|
|
804
1033
|
* `write`/`edit` normally, and equally inert when it offers neither those nor
|
|
805
|
-
*
|
|
1034
|
+
* a patch tool (the caller's unavailable-tool rejection still applies).
|
|
806
1035
|
*/
|
|
807
1036
|
export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceRoot) {
|
|
1037
|
+
const advertised = new Set(advertisedToolNames);
|
|
1038
|
+
if (isHostPatchTool(parsed.toolName)) {
|
|
1039
|
+
if (advertised.has(parsed.toolName))
|
|
1040
|
+
preserveDirectPatchLineEndings(parsed, workspaceRoot);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
808
1043
|
if (parsed.toolName !== "write" && parsed.toolName !== "edit")
|
|
809
1044
|
return;
|
|
810
|
-
//
|
|
1045
|
+
// Patch envelopes are text. A binary write has no patch form at all,
|
|
811
1046
|
// so it must stay a write and reach the byte-preserving path — converting it
|
|
812
1047
|
// here would drop the bytes and report a patch-translation failure instead of
|
|
813
1048
|
// the image Cursor asked to save.
|
|
814
1049
|
if (binaryWritePayload(parsed))
|
|
815
1050
|
return;
|
|
816
|
-
const
|
|
817
|
-
if (advertised.has(parsed.toolName) || !
|
|
1051
|
+
const patchTool = hostPatchTool(advertised);
|
|
1052
|
+
if (advertised.has(parsed.toolName) || !patchTool)
|
|
818
1053
|
return;
|
|
819
1054
|
const requested = parsed.toolName;
|
|
820
1055
|
const filePath = opencodePathArg(parsed.args);
|
|
821
1056
|
const refuse = (reason) => {
|
|
822
|
-
parsed.toolName =
|
|
1057
|
+
parsed.toolName = patchTool;
|
|
823
1058
|
parsed.args = {};
|
|
824
1059
|
parsed.localError =
|
|
825
|
-
`Cursor ${requested} request cannot be expressed as
|
|
826
|
-
|
|
1060
|
+
`Cursor ${requested} request cannot be expressed as a call to \`${patchTool}\`: ${reason}. ` +
|
|
1061
|
+
`The host advertises \`${patchTool}\` instead of \`edit\`/\`write\` for this model.`;
|
|
827
1062
|
};
|
|
828
1063
|
if (!filePath) {
|
|
829
1064
|
refuse("no target path was provided");
|
|
830
1065
|
return;
|
|
831
1066
|
}
|
|
832
1067
|
let patchText;
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
}
|
|
839
|
-
// `*** Add File:` overwrites an existing target, so a whole-file write maps
|
|
840
|
-
// directly without reading the current contents or diffing.
|
|
841
|
-
patchText = buildAddFilePatch(filePath, content);
|
|
842
|
-
}
|
|
843
|
-
else {
|
|
844
|
-
const oldString = stringValue(parsed.args.oldString);
|
|
845
|
-
const newString = stringValue(parsed.args.newString);
|
|
846
|
-
if (oldString === undefined || newString === undefined) {
|
|
847
|
-
refuse("the replacement is missing its old or new text");
|
|
848
|
-
return;
|
|
849
|
-
}
|
|
850
|
-
// apply_patch matches whole lines, so a substring edit has to be widened to
|
|
851
|
-
// the lines it touches — which requires the file the model is editing.
|
|
852
|
-
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
853
|
-
let source;
|
|
854
|
-
try {
|
|
855
|
-
// Bounded because this read is synchronous on the Run pump. Cursor's own
|
|
856
|
-
// executor uses the same 50 MB threshold before reading a file it is
|
|
857
|
-
// about to write.
|
|
858
|
-
const size = fs.statSync(absolute).size;
|
|
859
|
-
if (size > MAX_EDIT_SOURCE_BYTES) {
|
|
860
|
-
refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
|
|
1068
|
+
try {
|
|
1069
|
+
if (requested === "write") {
|
|
1070
|
+
const content = stringValue(parsed.args.content);
|
|
1071
|
+
if (content === undefined) {
|
|
1072
|
+
refuse("no file content was provided");
|
|
861
1073
|
return;
|
|
862
1074
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
refuse(`the target file could not be read (${e.message})`);
|
|
867
|
-
return;
|
|
1075
|
+
// `*** Add File:` overwrites an existing target, so a whole-file write maps
|
|
1076
|
+
// directly without reading the current contents or diffing.
|
|
1077
|
+
patchText = buildAddFilePatch(filePath, content, patchTool);
|
|
868
1078
|
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1079
|
+
else {
|
|
1080
|
+
const oldString = stringValue(parsed.args.oldString);
|
|
1081
|
+
const newString = stringValue(parsed.args.newString);
|
|
1082
|
+
if (oldString === undefined || newString === undefined) {
|
|
1083
|
+
refuse("the replacement is missing its old or new text");
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
// A patch matches whole lines, so a substring edit has to be widened to
|
|
1087
|
+
// the lines it touches — which requires the file the model is editing.
|
|
1088
|
+
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
1089
|
+
let source;
|
|
1090
|
+
try {
|
|
1091
|
+
// Bounded because this read is synchronous on the Run pump. Cursor's own
|
|
1092
|
+
// executor uses the same 50 MB threshold before reading a file it is
|
|
1093
|
+
// about to write.
|
|
1094
|
+
const stat = fs.statSync(absolute);
|
|
1095
|
+
if (!stat.isFile()) {
|
|
1096
|
+
refuse("the target is not a regular file");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const size = stat.size;
|
|
1100
|
+
if (size > MAX_EDIT_SOURCE_BYTES) {
|
|
1101
|
+
refuse(`the target file is ${Math.round(size / 1024 / 1024)} MB, too large to patch`);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
source = fs.readFileSync(absolute, "utf8");
|
|
1105
|
+
}
|
|
1106
|
+
catch (e) {
|
|
1107
|
+
refuse(`the target file could not be read (${e.message})`);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (patchTool === "patch" && /\r(?:\n|$)/.test(source)) {
|
|
1111
|
+
refuse("the patch tool strips carriage returns from line endings; the source content cannot be preserved");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
// Preserve native edit semantics rather than Cursor's whole-file wire
|
|
1115
|
+
// representation. OpenCode 1.x normalizes both edit arguments to the
|
|
1116
|
+
// source's newline convention; Cursor may send LF even after reading CRLF.
|
|
1117
|
+
// https://github.com/anomalyco/opencode/blob/v1.19.1/packages/opencode/src/tool/edit.ts
|
|
1118
|
+
const lineEnding = source.includes("\r\n") ? "\r\n" : "\n";
|
|
1119
|
+
const editText = (value) => patchTool === "apply_patch"
|
|
1120
|
+
? value.replace(/\r\n/g, "\n").replace(/\n/g, lineEnding)
|
|
1121
|
+
: value;
|
|
1122
|
+
const plan = planSubstringEdit(source, editText(oldString), editText(newString), parsed.args.replaceAll === true);
|
|
1123
|
+
if (!plan.ok) {
|
|
1124
|
+
refuse(plan.reason);
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
patchText = buildUpdateFilePatch(filePath, plan.chunks, patchTool);
|
|
873
1128
|
}
|
|
874
|
-
patchText = buildUpdateFilePatch(filePath, plan.chunks);
|
|
875
1129
|
}
|
|
876
|
-
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
refuse(error instanceof Error ? error.message : String(error));
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
parsed.toolName = patchTool;
|
|
877
1135
|
parsed.args = { patchText };
|
|
878
|
-
//
|
|
1136
|
+
// Patch output does not carry opencode `write`'s <path> tag, so keep
|
|
879
1137
|
// the requested path for the typed Cursor result.
|
|
880
1138
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
881
1139
|
}
|
|
@@ -918,7 +1176,7 @@ export function rejectPartialReadMutation(parsed) {
|
|
|
918
1176
|
if (parsed.toolName === "write") {
|
|
919
1177
|
content = parsed.args.content;
|
|
920
1178
|
}
|
|
921
|
-
else if (parsed.toolName
|
|
1179
|
+
else if (isHostPatchTool(parsed.toolName)) {
|
|
922
1180
|
const patchText = parsed.args.patchText;
|
|
923
1181
|
if (typeof patchText !== "string" || !patchText.includes("*** Add File:"))
|
|
924
1182
|
return;
|
|
@@ -987,6 +1245,7 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
987
1245
|
background_shell_spawn: true,
|
|
988
1246
|
command: command ?? "",
|
|
989
1247
|
working_directory: workingDirectory,
|
|
1248
|
+
...(str(raw.description) ? { description: str(raw.description) } : {}),
|
|
990
1249
|
},
|
|
991
1250
|
localError: raw.enable_write_shell_stdin_tool === true
|
|
992
1251
|
? "Interactive background shells are not available through OpenCode's bash tool."
|
|
@@ -1086,7 +1345,9 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1086
1345
|
? writeRequestResultMetadata(rawArgs, mapped.args)
|
|
1087
1346
|
: execVariant === "grep_args"
|
|
1088
1347
|
? grepRequestResultMetadata(rawArgs)
|
|
1089
|
-
:
|
|
1348
|
+
: execVariant === "delete_args" || execVariant === "ls_args"
|
|
1349
|
+
? { path: str(rawArgs.path) ?? "" }
|
|
1350
|
+
: undefined;
|
|
1090
1351
|
if (resultMetadata
|
|
1091
1352
|
&& resultMetadata.timeout_behavior !== 2
|
|
1092
1353
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1129,6 +1390,7 @@ function shellStreamResultMetadata(raw) {
|
|
|
1129
1390
|
return {
|
|
1130
1391
|
shell_stream: true,
|
|
1131
1392
|
command: str(raw.command) ?? "",
|
|
1393
|
+
...(str(raw.description) ? { description: str(raw.description) } : {}),
|
|
1132
1394
|
working_directory: str(raw.working_directory) ?? "",
|
|
1133
1395
|
timeout_ms: effectiveTimeout,
|
|
1134
1396
|
timeout_behavior: timeoutBehavior,
|
|
@@ -1163,10 +1425,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1163
1425
|
// as any other bash tool call.
|
|
1164
1426
|
if (execVariant === "delete_args") {
|
|
1165
1427
|
const target = str(cleaned.path) ?? str(cleaned.filePath);
|
|
1166
|
-
return {
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
};
|
|
1428
|
+
return mapCursorArgsToOpencode(dialect.shellTool, {
|
|
1429
|
+
command: target ? `rm -f -- ${shellQuote(target)}` : "true",
|
|
1430
|
+
}, undefined, dialect);
|
|
1170
1431
|
}
|
|
1171
1432
|
switch (toolName) {
|
|
1172
1433
|
case "read": {
|
|
@@ -1237,12 +1498,24 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1237
1498
|
const command = str(cleaned.command);
|
|
1238
1499
|
if (command)
|
|
1239
1500
|
args.command = command;
|
|
1501
|
+
if (dialect.shellDescription) {
|
|
1502
|
+
const description = str(cleaned.description)
|
|
1503
|
+
?? (dialect.shellDescription === "required" && command
|
|
1504
|
+
? `Run: ${command.length > 60 ? `${command.slice(0, 57)}...` : command}`
|
|
1505
|
+
: undefined);
|
|
1506
|
+
if (description)
|
|
1507
|
+
args.description = description;
|
|
1508
|
+
}
|
|
1240
1509
|
const workdir = str(cleaned.workdir) ?? str(cleaned.working_directory);
|
|
1241
1510
|
if (workdir)
|
|
1242
1511
|
args.workdir = workdir;
|
|
1243
1512
|
const timeout = num(cleaned.timeout);
|
|
1244
1513
|
if (timeout !== undefined)
|
|
1245
1514
|
args.timeout = timeout;
|
|
1515
|
+
// The model only sends this when the host's advertised shell schema has
|
|
1516
|
+
// it (OpenCode 2); the host then owns the background job and its notice.
|
|
1517
|
+
if (cleaned.background === true)
|
|
1518
|
+
args.background = true;
|
|
1246
1519
|
return { toolName: dialect.shellTool, args };
|
|
1247
1520
|
}
|
|
1248
1521
|
case "grep": {
|
|
@@ -1288,6 +1561,22 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1288
1561
|
args[dialect.skillArgKey] = value;
|
|
1289
1562
|
return { toolName: "skill", args };
|
|
1290
1563
|
}
|
|
1564
|
+
case "todowrite": {
|
|
1565
|
+
// OpenCode requires `priority` on every todo item. Cursor TodoWrite /
|
|
1566
|
+
// model MCP calls often omit it; fill host defaults before validation.
|
|
1567
|
+
return {
|
|
1568
|
+
toolName: "todowrite",
|
|
1569
|
+
args: normalizeOpencodeTodoArgs(cleaned),
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
case "question": {
|
|
1573
|
+
// OpenCode requires `header` on every question. Model MCP calls often
|
|
1574
|
+
// omit it (AskQuestion bridge already fills it); default before validation.
|
|
1575
|
+
return {
|
|
1576
|
+
toolName: "question",
|
|
1577
|
+
args: normalizeOpencodeQuestionArgs(cleaned),
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1291
1580
|
default:
|
|
1292
1581
|
return { toolName, args: cleaned };
|
|
1293
1582
|
}
|
|
@@ -1511,6 +1800,9 @@ export function classifyMissingReadTarget(absolutePath) {
|
|
|
1511
1800
|
}
|
|
1512
1801
|
return undefined;
|
|
1513
1802
|
}
|
|
1803
|
+
return classifyReadTargetStat(absolutePath, stat);
|
|
1804
|
+
}
|
|
1805
|
+
function classifyReadTargetStat(absolutePath, stat) {
|
|
1514
1806
|
if (stat.isDirectory()) {
|
|
1515
1807
|
return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
|
|
1516
1808
|
}
|
|
@@ -1539,6 +1831,24 @@ export function buildReadRejectionMessages(execId, readResult) {
|
|
|
1539
1831
|
buildExecStreamClose(execId),
|
|
1540
1832
|
];
|
|
1541
1833
|
}
|
|
1834
|
+
/**
|
|
1835
|
+
* The leading tool-result images an exec result can carry on a held Run.
|
|
1836
|
+
* Cursor's own read executor answers an image file with its bytes in
|
|
1837
|
+
* `ReadSuccess.data` (#5) and no text; its MCP executor appends each MCP image
|
|
1838
|
+
* as an `McpImageContent` item (#2). Other result shapes have no image field.
|
|
1839
|
+
*/
|
|
1840
|
+
export function execResultImages(resultField, images) {
|
|
1841
|
+
if (!images?.length)
|
|
1842
|
+
return [];
|
|
1843
|
+
if (resultField === "read_result")
|
|
1844
|
+
return [images[0]];
|
|
1845
|
+
if (resultField === "mcp_result")
|
|
1846
|
+
return [...images];
|
|
1847
|
+
return [];
|
|
1848
|
+
}
|
|
1849
|
+
function mcpImageItems(images) {
|
|
1850
|
+
return images.map((image) => ({ image: { data: image.data, mime_type: image.mimeType } }));
|
|
1851
|
+
}
|
|
1542
1852
|
/**
|
|
1543
1853
|
* Build one or more ExecClientMessage frames for a tool result.
|
|
1544
1854
|
* Shell replies are a sequence of ShellStream oneofs under the same id —
|
|
@@ -1547,20 +1857,28 @@ export function buildReadRejectionMessages(execId, readResult) {
|
|
|
1547
1857
|
* shell execs hang on heartbeats forever).
|
|
1548
1858
|
*/
|
|
1549
1859
|
export function buildExecClientMessages(input) {
|
|
1860
|
+
if (input.error === "")
|
|
1861
|
+
return buildExecClientMessages({ ...input, error: "OpenCode tool execution failed" });
|
|
1550
1862
|
const resultField = input.resultField || "mcp_result";
|
|
1551
1863
|
const frames = [];
|
|
1552
|
-
|
|
1553
|
-
|
|
1864
|
+
const refused = refusedExecResult(input);
|
|
1865
|
+
if (resultField === "shell_stream" && refused) {
|
|
1866
|
+
frames.push(encodeShellStream(input.execId, input.executionTimeMs, refused));
|
|
1867
|
+
}
|
|
1868
|
+
else if (resultField === "shell_stream") {
|
|
1554
1869
|
// Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
|
|
1555
1870
|
frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
|
|
1871
|
+
// Retain progress even when the host also reports a failure or interruption.
|
|
1872
|
+
// A command may change directories; stdout must reach Cursor unchanged.
|
|
1873
|
+
if (input.output) {
|
|
1874
|
+
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: input.output } }));
|
|
1875
|
+
}
|
|
1556
1876
|
if (input.error) {
|
|
1557
1877
|
frames.push(encodeShellStream(input.execId, undefined, { stderr: { data: input.error } }));
|
|
1558
|
-
frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit:
|
|
1878
|
+
frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: input.interrupted || input.shellOutcome?.kind === "aborted"
|
|
1879
|
+
? { code: 0, aborted: true, abort_reason: 1 } : { code: 1, aborted: false } }));
|
|
1559
1880
|
}
|
|
1560
1881
|
else {
|
|
1561
|
-
if (stdout) {
|
|
1562
|
-
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
|
|
1563
|
-
}
|
|
1564
1882
|
if (input.shellOutcome?.kind === "backgrounded") {
|
|
1565
1883
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
1566
1884
|
backgrounded: {
|
|
@@ -1579,6 +1897,11 @@ export function buildExecClientMessages(input) {
|
|
|
1579
1897
|
exit: { code: 0, aborted: true, abort_reason: 2 },
|
|
1580
1898
|
}));
|
|
1581
1899
|
}
|
|
1900
|
+
else if (input.shellOutcome?.kind === "aborted") {
|
|
1901
|
+
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
1902
|
+
exit: { code: 0, aborted: true, abort_reason: 1 },
|
|
1903
|
+
}));
|
|
1904
|
+
}
|
|
1582
1905
|
else {
|
|
1583
1906
|
const exitCode = input.shellOutcome?.kind === "exit"
|
|
1584
1907
|
? Math.max(0, Math.min(0xffff_ffff, input.shellOutcome.code))
|
|
@@ -1594,7 +1917,14 @@ export function buildExecClientMessages(input) {
|
|
|
1594
1917
|
id: input.execId,
|
|
1595
1918
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
1596
1919
|
};
|
|
1597
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot);
|
|
1920
|
+
clientMsg[resultField] = refused ?? buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot, execResultImages(resultField, input.images));
|
|
1921
|
+
if ((input.interrupted || input.shellOutcome?.kind === "aborted") && resultField === "shell_result") {
|
|
1922
|
+
const failure = clientMsg[resultField].failure;
|
|
1923
|
+
if (failure) {
|
|
1924
|
+
failure.aborted = true;
|
|
1925
|
+
failure.exit_code = 0;
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1598
1928
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
1599
1929
|
exec_client_message: clientMsg,
|
|
1600
1930
|
}));
|
|
@@ -1603,6 +1933,47 @@ export function buildExecClientMessages(input) {
|
|
|
1603
1933
|
frames.push(buildExecStreamClose(input.execId));
|
|
1604
1934
|
return frames;
|
|
1605
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Attach host notes to an exec result as Cursor CLI postToolUse hook context:
|
|
1938
|
+
* `ExecClientMessage.hook_additional_contexts` (#45) on a unary result, or a
|
|
1939
|
+
* `ShellStream.hook_context` (#8) event after the stream's own events and
|
|
1940
|
+
* before `stream_close` (CLI agent-exec hook wrapper). Cursor renders it as a
|
|
1941
|
+
* system reminder on that tool result, so it never mixes into file content.
|
|
1942
|
+
* Returns undefined when the frames carry no exec result (bridged
|
|
1943
|
+
* interaction replies have no such field).
|
|
1944
|
+
*/
|
|
1945
|
+
export function attachHookAdditionalContexts(frames, contexts) {
|
|
1946
|
+
const root = getMessageTypes();
|
|
1947
|
+
const type = root.lookupType("AgentClientMessage");
|
|
1948
|
+
const contextType = root.lookupType("HookAdditionalContext");
|
|
1949
|
+
let shellStreamAt = -1;
|
|
1950
|
+
let shellStreamId = 0;
|
|
1951
|
+
for (let index = 0; index < frames.length; index++) {
|
|
1952
|
+
const message = type.decode(frames[index]);
|
|
1953
|
+
const exec = message.exec_client_message;
|
|
1954
|
+
if (!exec?.result)
|
|
1955
|
+
continue;
|
|
1956
|
+
if (exec.result === "shell_stream") {
|
|
1957
|
+
shellStreamAt = index;
|
|
1958
|
+
shellStreamId = exec.id ?? 0;
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
exec.hook_additional_contexts = contexts.map((context) => contextType.fromObject(context));
|
|
1962
|
+
const out = [...frames];
|
|
1963
|
+
out[index] = type.encode(message).finish();
|
|
1964
|
+
return out;
|
|
1965
|
+
}
|
|
1966
|
+
if (shellStreamAt === -1)
|
|
1967
|
+
return undefined;
|
|
1968
|
+
const out = [...frames];
|
|
1969
|
+
out.splice(shellStreamAt + 1, 0, encodeMessage("AgentClientMessage", {
|
|
1970
|
+
exec_client_message: {
|
|
1971
|
+
id: shellStreamId,
|
|
1972
|
+
shell_stream: { hook_context: { hook_additional_contexts: [...contexts] } },
|
|
1973
|
+
},
|
|
1974
|
+
}));
|
|
1975
|
+
return out;
|
|
1976
|
+
}
|
|
1606
1977
|
/** ACM #5 exec_client_control_message { stream_close { id } }. */
|
|
1607
1978
|
export function buildExecStreamClose(execId) {
|
|
1608
1979
|
return encodeMessage("AgentClientMessage", {
|
|
@@ -1611,6 +1982,38 @@ export function buildExecStreamClose(execId) {
|
|
|
1611
1982
|
},
|
|
1612
1983
|
});
|
|
1613
1984
|
}
|
|
1985
|
+
/** Refusal oneofs verified against the extracted agent.v1 protocol. */
|
|
1986
|
+
function refusedExecResult(input) {
|
|
1987
|
+
if (!input.refusal || !input.error)
|
|
1988
|
+
return undefined;
|
|
1989
|
+
const denied = input.refusal === "permission_denied";
|
|
1990
|
+
const reason = input.error;
|
|
1991
|
+
const requested = str(input.resultMetadata?.path) ?? "";
|
|
1992
|
+
const filePath = requested && input.workspaceRoot && !isUriReadTarget(requested)
|
|
1993
|
+
? resolveReadTargetPath(requested, input.workspaceRoot) : requested;
|
|
1994
|
+
const command = str(input.resultMetadata?.command) ?? "";
|
|
1995
|
+
const working_directory = str(input.resultMetadata?.working_directory) ?? "";
|
|
1996
|
+
switch (input.resultField) {
|
|
1997
|
+
case "shell_stream":
|
|
1998
|
+
case "shell_result":
|
|
1999
|
+
case "background_shell_spawn_result":
|
|
2000
|
+
return denied ? { permission_denied: { command, working_directory, error: reason } }
|
|
2001
|
+
: { rejected: { command, working_directory, reason } };
|
|
2002
|
+
case "write_result":
|
|
2003
|
+
return denied ? { permission_denied: { path: filePath, directory: filePath ? path.dirname(filePath) : "", operation: "write", error: reason, is_readonly: false } }
|
|
2004
|
+
: { rejected: { path: filePath, reason } };
|
|
2005
|
+
case "read_result":
|
|
2006
|
+
return denied ? { permission_denied: { path: filePath } } : { rejected: { path: filePath, reason } };
|
|
2007
|
+
case "ls_result": return { rejected: { path: filePath, reason } };
|
|
2008
|
+
case "delete_result":
|
|
2009
|
+
return denied ? { permission_denied: { path: filePath, client_visible_error: reason, is_readonly: false } }
|
|
2010
|
+
: { rejected: { path: filePath, reason } };
|
|
2011
|
+
case "mcp_result":
|
|
2012
|
+
return denied ? { permission_denied: { error: reason, is_readonly: false } }
|
|
2013
|
+
: { rejected: { reason, is_readonly: false } };
|
|
2014
|
+
default: return undefined;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
1614
2017
|
export function buildUnsupportedExecDeny(input) {
|
|
1615
2018
|
const { execId, variant, reason } = input;
|
|
1616
2019
|
const resultName = variant.resultName;
|
|
@@ -2006,21 +2409,6 @@ function splitForeignAbsolute(filePath, sep) {
|
|
|
2006
2409
|
frozen: 0,
|
|
2007
2410
|
};
|
|
2008
2411
|
}
|
|
2009
|
-
function isRelativePathToken(token) {
|
|
2010
|
-
if (!token || isAbsoluteToolPath(token))
|
|
2011
|
-
return false;
|
|
2012
|
-
// A slash alone does not make arbitrary shell output a path. In particular,
|
|
2013
|
-
// compact JSON, quoted strings, package ids, and shell syntax must remain
|
|
2014
|
-
// byte-for-byte model-visible rather than being prefixed with the workspace.
|
|
2015
|
-
if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
|
|
2016
|
-
return false;
|
|
2017
|
-
if (token.startsWith("./")
|
|
2018
|
-
|| token.startsWith("../")
|
|
2019
|
-
|| token.startsWith(".\\")
|
|
2020
|
-
|| token.startsWith("..\\"))
|
|
2021
|
-
return true;
|
|
2022
|
-
return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
|
|
2023
|
-
}
|
|
2024
2412
|
function resolveListedEntry(directory, entry) {
|
|
2025
2413
|
if (!entry || isAbsoluteToolPath(entry))
|
|
2026
2414
|
return entry;
|
|
@@ -2236,46 +2624,6 @@ function rewriteSearchPathLine(line, workspaceRoot) {
|
|
|
2236
2624
|
return line;
|
|
2237
2625
|
return resolveToolPath(line, workspaceRoot);
|
|
2238
2626
|
}
|
|
2239
|
-
/**
|
|
2240
|
-
* Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
|
|
2241
|
-
* paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
|
|
2242
|
-
* status words alone.
|
|
2243
|
-
*/
|
|
2244
|
-
function groundShellPathText(output, root) {
|
|
2245
|
-
if (!root || !output)
|
|
2246
|
-
return output;
|
|
2247
|
-
return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
|
|
2248
|
-
}
|
|
2249
|
-
function shellPathRoot(resultMetadata, workspaceRoot) {
|
|
2250
|
-
const workingDirectory = str(resultMetadata?.working_directory)?.trim();
|
|
2251
|
-
if (!workingDirectory)
|
|
2252
|
-
return workspaceRoot;
|
|
2253
|
-
if (isAbsoluteToolPath(workingDirectory))
|
|
2254
|
-
return workingDirectory;
|
|
2255
|
-
return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
|
|
2256
|
-
}
|
|
2257
|
-
function rewriteShellPathLine(line, root) {
|
|
2258
|
-
if (!line || line.startsWith(" ") || line.startsWith("\t"))
|
|
2259
|
-
return line;
|
|
2260
|
-
const trimmed = line.trimEnd();
|
|
2261
|
-
if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
2262
|
-
return line;
|
|
2263
|
-
}
|
|
2264
|
-
const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
|
|
2265
|
-
if (located && located[1] && isRelativePathToken(located[1])) {
|
|
2266
|
-
const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
|
|
2267
|
-
return `${resolveToolPath(located[1], root)}${suffix}`;
|
|
2268
|
-
}
|
|
2269
|
-
if (/\s/.test(trimmed))
|
|
2270
|
-
return line;
|
|
2271
|
-
const header = /^(.*):$/.exec(trimmed);
|
|
2272
|
-
if (header && header[1] && isRelativePathToken(header[1])) {
|
|
2273
|
-
return `${resolveToolPath(header[1], root)}:`;
|
|
2274
|
-
}
|
|
2275
|
-
if (isRelativePathToken(trimmed))
|
|
2276
|
-
return resolveToolPath(trimmed, root);
|
|
2277
|
-
return line;
|
|
2278
|
-
}
|
|
2279
2627
|
const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
|
|
2280
2628
|
/**
|
|
2281
2629
|
* OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
|
|
@@ -2458,12 +2806,36 @@ export function unwrapReadOutput(output) {
|
|
|
2458
2806
|
// than the envelope (the envelope is exactly what Cursor echoes into writes).
|
|
2459
2807
|
return raw.join("\n");
|
|
2460
2808
|
}
|
|
2809
|
+
/**
|
|
2810
|
+
* OpenCode's read tool appends newly discovered instructions after the
|
|
2811
|
+
* envelope's unnumbered `</content>`, in one `<system-reminder>` block.
|
|
2812
|
+
* The numbered-body parser stops before that block, so callers must lift it
|
|
2813
|
+
* out before the file text is encoded.
|
|
2814
|
+
*/
|
|
2815
|
+
export function extractOpenCodeReadInstruction(output) {
|
|
2816
|
+
if (!output.includes("<content>") || !output.includes("<system-reminder>"))
|
|
2817
|
+
return undefined;
|
|
2818
|
+
const contentHeaderIdx = output.indexOf("<content>");
|
|
2819
|
+
const header = output.slice(0, contentHeaderIdx);
|
|
2820
|
+
if (!header.includes("<path>") || !header.includes("<type>file</type>"))
|
|
2821
|
+
return undefined;
|
|
2822
|
+
const rest = output.slice(contentHeaderIdx + "<content>".length);
|
|
2823
|
+
const closeAt = rest.search(/(?:^|\n)<\/content>(?:\n|$)/);
|
|
2824
|
+
if (closeAt === -1)
|
|
2825
|
+
return undefined;
|
|
2826
|
+
const afterClose = rest.slice(closeAt).replace(/^(?:\n)?<\/content>/, "");
|
|
2827
|
+
const match = /^\s*<system-reminder>\n([\s\S]*?)\n<\/system-reminder>/.exec(afterClose);
|
|
2828
|
+
const body = match?.[1]?.trim();
|
|
2829
|
+
return body ? body : undefined;
|
|
2830
|
+
}
|
|
2461
2831
|
/**
|
|
2462
2832
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
2463
2833
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
2464
2834
|
* server accepts (verified against agent.v1 wire captures).
|
|
2465
2835
|
*/
|
|
2466
|
-
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
2836
|
+
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot, images = []) {
|
|
2837
|
+
if (error === "")
|
|
2838
|
+
error = "OpenCode tool execution failed";
|
|
2467
2839
|
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
2468
2840
|
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
2469
2841
|
const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
|
|
@@ -2483,6 +2855,18 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2483
2855
|
const readPath = resolveToolPath(rawPath, resultRoot);
|
|
2484
2856
|
if (error)
|
|
2485
2857
|
return { error: { path: readPath, error } };
|
|
2858
|
+
const image = images[0];
|
|
2859
|
+
if (image) {
|
|
2860
|
+
return {
|
|
2861
|
+
success: {
|
|
2862
|
+
path: readPath,
|
|
2863
|
+
data: image.data,
|
|
2864
|
+
total_lines: 0,
|
|
2865
|
+
file_size: readFileSize(readPath) || image.data.length,
|
|
2866
|
+
truncated: false,
|
|
2867
|
+
},
|
|
2868
|
+
};
|
|
2869
|
+
}
|
|
2486
2870
|
// The host may have resolved an alternate spelling after the request
|
|
2487
2871
|
// (for example a Unicode-space filename). Use the result's own path for
|
|
2488
2872
|
// local stat/newline recovery while preserving the requested path in the
|
|
@@ -2572,7 +2956,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2572
2956
|
};
|
|
2573
2957
|
}
|
|
2574
2958
|
case "write_result": {
|
|
2575
|
-
// Prefer the path recorded from WriteArgs / a remapped
|
|
2959
|
+
// Prefer the path recorded from WriteArgs / a remapped patch write.
|
|
2576
2960
|
// Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
|
|
2577
2961
|
// spill copies WriteSuccess.path into the model-visible filePath.
|
|
2578
2962
|
const remappedPath = str(resultMetadata?.path);
|
|
@@ -2603,16 +2987,15 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2603
2987
|
case "shell_result": {
|
|
2604
2988
|
const command = str(resultMetadata?.command) ?? "";
|
|
2605
2989
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2606
|
-
|
|
2607
|
-
if (error) {
|
|
2990
|
+
if (error || shellOutcome?.kind === "aborted") {
|
|
2608
2991
|
return {
|
|
2609
2992
|
failure: {
|
|
2610
2993
|
command,
|
|
2611
2994
|
working_directory: workingDirectory,
|
|
2612
|
-
exit_code: 1,
|
|
2613
|
-
stdout:
|
|
2614
|
-
stderr: error,
|
|
2615
|
-
aborted:
|
|
2995
|
+
exit_code: shellOutcome?.kind === "aborted" ? 0 : 1,
|
|
2996
|
+
stdout: output || "",
|
|
2997
|
+
stderr: error || "Shell execution interrupted",
|
|
2998
|
+
aborted: shellOutcome?.kind === "aborted",
|
|
2616
2999
|
},
|
|
2617
3000
|
};
|
|
2618
3001
|
}
|
|
@@ -2631,7 +3014,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2631
3014
|
command: shellOutcome.command || command,
|
|
2632
3015
|
working_directory: shellOutcome.workingDirectory || workingDirectory,
|
|
2633
3016
|
exit_code: 0,
|
|
2634
|
-
stdout:
|
|
3017
|
+
stdout: output,
|
|
2635
3018
|
shell_id: shellOutcome.shellId,
|
|
2636
3019
|
pid: shellOutcome.pid,
|
|
2637
3020
|
ms_to_wait: shellOutcome.msToWait,
|
|
@@ -2649,14 +3032,11 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2649
3032
|
command,
|
|
2650
3033
|
working_directory: workingDirectory,
|
|
2651
3034
|
exit_code: exitCode,
|
|
2652
|
-
stdout:
|
|
3035
|
+
stdout: output,
|
|
2653
3036
|
},
|
|
2654
3037
|
};
|
|
2655
3038
|
}
|
|
2656
3039
|
case "pi_bash_result":
|
|
2657
|
-
if (error)
|
|
2658
|
-
return { error: { error } };
|
|
2659
|
-
return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
|
|
2660
3040
|
case "pi_edit_result":
|
|
2661
3041
|
if (error)
|
|
2662
3042
|
return { error: { error } };
|
|
@@ -2674,13 +3054,13 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2674
3054
|
}
|
|
2675
3055
|
case "delete_result":
|
|
2676
3056
|
if (error)
|
|
2677
|
-
return { error: { path: "", error } };
|
|
2678
|
-
return { success: { path: "", deleted_file: "" } };
|
|
3057
|
+
return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
|
|
3058
|
+
return { success: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), deleted_file: "" } };
|
|
2679
3059
|
case "background_shell_spawn_result": {
|
|
2680
3060
|
const command = str(resultMetadata?.command) ?? "";
|
|
2681
3061
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2682
|
-
if (error)
|
|
2683
|
-
return { error: { command, working_directory: workingDirectory, error } };
|
|
3062
|
+
if (error || shellOutcome?.kind === "aborted")
|
|
3063
|
+
return { error: { command, working_directory: workingDirectory, error: error || "Shell execution interrupted" } };
|
|
2684
3064
|
// Prefer the structured outcome captured by the plugin after-hook; markers
|
|
2685
3065
|
// are already stripped from the stored/rendered OpenCode output by then.
|
|
2686
3066
|
if (shellOutcome?.kind === "backgrounded") {
|
|
@@ -2694,8 +3074,10 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2694
3074
|
};
|
|
2695
3075
|
}
|
|
2696
3076
|
// Fallback when no plugin hook ran: parse the private spawn marker inline.
|
|
2697
|
-
|
|
2698
|
-
const
|
|
3077
|
+
// A managed spawn reports `shell-id:pid:file`; the unmanaged wrapper `pid:file`.
|
|
3078
|
+
const match = new RegExp(`${BACKGROUND_SHELL_MARKER}(\\d+):(?:(\\d+):)?([^\\r\\n]+)`).exec(output);
|
|
3079
|
+
const shellId = match ? Number(match[1]) : 0;
|
|
3080
|
+
const pid = match ? Number(match[2] || match[1]) : 0;
|
|
2699
3081
|
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
|
|
2700
3082
|
return {
|
|
2701
3083
|
error: {
|
|
@@ -2707,7 +3089,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2707
3089
|
}
|
|
2708
3090
|
return {
|
|
2709
3091
|
success: {
|
|
2710
|
-
shell_id:
|
|
3092
|
+
shell_id: shellId,
|
|
2711
3093
|
command,
|
|
2712
3094
|
working_directory: workingDirectory,
|
|
2713
3095
|
pid,
|
|
@@ -2716,7 +3098,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2716
3098
|
}
|
|
2717
3099
|
case "ls_result": {
|
|
2718
3100
|
if (error)
|
|
2719
|
-
return { error: { path: "", error } };
|
|
3101
|
+
return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
|
|
2720
3102
|
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2721
3103
|
const rootPath = listing?.directory || resultRoot || "";
|
|
2722
3104
|
const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
|
|
@@ -2750,18 +3132,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2750
3132
|
if (toolName === "grep" || toolName === "glob") {
|
|
2751
3133
|
return {
|
|
2752
3134
|
success: {
|
|
2753
|
-
content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
|
|
3135
|
+
content: [{ text: { text: groundSearchOutput(output, resultRoot) } }, ...mcpImageItems(images)],
|
|
2754
3136
|
is_error: false,
|
|
2755
3137
|
},
|
|
2756
3138
|
};
|
|
2757
3139
|
}
|
|
2758
3140
|
if (toolName !== "read") {
|
|
2759
|
-
return {
|
|
3141
|
+
return {
|
|
3142
|
+
success: { content: [{ text: { text: output } }, ...mcpImageItems(images)], is_error: false },
|
|
3143
|
+
};
|
|
2760
3144
|
}
|
|
2761
3145
|
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2762
3146
|
if (listing) {
|
|
2763
3147
|
return {
|
|
2764
|
-
success: { content: [{ text: { text: listing.text } }], is_error: false },
|
|
3148
|
+
success: { content: [{ text: { text: listing.text } }, ...mcpImageItems(images)], is_error: false },
|
|
2765
3149
|
};
|
|
2766
3150
|
}
|
|
2767
3151
|
// Carry the truncation notice as its own content item: the file content
|
|
@@ -2775,6 +3159,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2775
3159
|
content: [
|
|
2776
3160
|
{ text: { text: unwrapReadOutput(output) } },
|
|
2777
3161
|
...notices.map((notice) => ({ text: { text: notice } })),
|
|
3162
|
+
...mcpImageItems(images),
|
|
2778
3163
|
],
|
|
2779
3164
|
is_error: false,
|
|
2780
3165
|
},
|
|
@@ -3226,7 +3611,14 @@ export function buildMcpStateResult(execId, args, toolDescriptors) {
|
|
|
3226
3611
|
}
|
|
3227
3612
|
list.push({
|
|
3228
3613
|
name: stringValue(tool.name) ?? `${server}-${toolName}`,
|
|
3229
|
-
|
|
3614
|
+
// GetDynamicTools exposes the inner schema without the outer call identity.
|
|
3615
|
+
// Keep the exact identity in the definition, including truncated searches.
|
|
3616
|
+
description: `CallDynamicTool identity: ${JSON.stringify({ namespace: server, toolName })}. `
|
|
3617
|
+
+ `Complete outer envelope: ${JSON.stringify({ namespace: server, toolName, arguments: {} })}. `
|
|
3618
|
+
+ "Replace arguments with this inputSchema's object; keep both identity fields outside it. "
|
|
3619
|
+
+ "Write namespace and toolName FIRST, then arguments containing this inputSchema's object. "
|
|
3620
|
+
+ "All three outer fields are required on every invocation.\n\n"
|
|
3621
|
+
+ (stringValue(tool.description) ?? ""),
|
|
3230
3622
|
input_schema: tool.input_schema,
|
|
3231
3623
|
provider_identifier: server,
|
|
3232
3624
|
tool_name: toolName,
|