cursor-opencode-provider 0.8.1 → 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 +95 -0
- package/README.md +204 -37
- package/SECURITY.md +20 -8
- 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 +1 -0
- package/dist/context/build.js +32 -5
- package/dist/context/env.js +27 -3
- package/dist/context/epoch.js +9 -5
- package/dist/context/git.d.ts +5 -2
- package/dist/context/git.js +17 -7
- package/dist/context/paths.d.ts +1 -1
- package/dist/context/paths.js +7 -4
- 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-interrupt.d.ts +12 -0
- package/dist/host-interrupt.js +18 -0
- package/dist/host-steer.d.ts +71 -0
- package/dist/host-steer.js +232 -0
- package/dist/image-input.js +4 -3
- package/dist/index.d.ts +2 -2
- package/dist/language-model.d.ts +114 -23
- package/dist/language-model.js +2290 -469
- package/dist/lost-request.d.ts +39 -0
- package/dist/lost-request.js +111 -0
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +50 -29
- package/dist/models.d.ts +26 -3
- package/dist/models.js +101 -45
- package/dist/opencode2/tool-results.d.ts +18 -0
- package/dist/opencode2/tool-results.js +66 -0
- package/dist/plugin-opencode2.js +185 -25
- package/dist/plugin.js +28 -5
- package/dist/pricing-data.d.ts +26 -2
- package/dist/pricing-data.js +26 -2
- 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/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/host-notes.d.ts +31 -0
- package/dist/protocol/host-notes.js +85 -0
- package/dist/protocol/messages.js +91 -8
- package/dist/protocol/request.d.ts +35 -19
- package/dist/protocol/request.js +53 -28
- package/dist/protocol/sent-history-images.d.ts +49 -0
- package/dist/protocol/sent-history-images.js +102 -0
- package/dist/protocol/tool-call-bridge.d.ts +2 -0
- package/dist/protocol/tool-call-bridge.js +10 -0
- package/dist/protocol/tools.d.ts +74 -8
- package/dist/protocol/tools.js +431 -141
- package/dist/protocol/turn-provenance.d.ts +19 -4
- package/dist/protocol/turn-provenance.js +77 -20
- package/dist/replay-safety.d.ts +16 -4
- package/dist/replay-safety.js +34 -25
- 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 +94 -9
- package/dist/session.js +153 -34
- 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/transport/connect.d.ts +8 -1
- package/dist/transport/connect.js +103 -23
- package/dist/usage.d.ts +37 -17
- package/dist/usage.js +63 -29
- package/package.json +3 -2
- package/dist/protocol/thinking.d.ts +0 -15
- package/dist/protocol/thinking.js +0 -17
package/dist/protocol/tools.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
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
12
|
import { normalizeOpencodeQuestionArgs } from "./ask-question.js";
|
|
13
13
|
import { normalizeOpencodeTodoArgs } from "../todo-store.js";
|
|
@@ -667,6 +667,46 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
667
667
|
}
|
|
668
668
|
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
669
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
|
+
}
|
|
670
710
|
/**
|
|
671
711
|
* Complete the private read inside Cursor's legacy edit transaction without
|
|
672
712
|
* routing it through OpenCode's user-facing `read` tool. The latter caps every
|
|
@@ -706,6 +746,60 @@ export function buildCompleteEditReadMessages(execId, sourcePath, resultPath = s
|
|
|
706
746
|
return undefined;
|
|
707
747
|
}
|
|
708
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
|
+
}
|
|
709
803
|
/** Return the number of non-overlapping occurrences, stopping after ambiguity. */
|
|
710
804
|
function replacementOccurrenceCount(source, needle) {
|
|
711
805
|
if (!needle)
|
|
@@ -778,6 +872,41 @@ function planWholeFileEdit(source, target) {
|
|
|
778
872
|
}
|
|
779
873
|
return result;
|
|
780
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
|
+
}
|
|
781
910
|
/**
|
|
782
911
|
* Preserve a legacy Cursor edit's intent when its internal executor follows
|
|
783
912
|
* `edit_tool_call` with a whole-file `write_args` request.
|
|
@@ -789,7 +918,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
789
918
|
if (parsed.resultField !== "write_result" || parsed.toolName !== "write")
|
|
790
919
|
return false;
|
|
791
920
|
const advertised = new Set(advertisedToolNames);
|
|
792
|
-
if (!advertised.has("edit") && !advertised
|
|
921
|
+
if (!advertised.has("edit") && !hostPatchTool(advertised))
|
|
793
922
|
return false;
|
|
794
923
|
const filePath = opencodePathArg(parsed.args);
|
|
795
924
|
const content = stringValue(parsed.args.content);
|
|
@@ -828,93 +957,183 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
828
957
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
829
958
|
return true;
|
|
830
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
|
+
}
|
|
831
1021
|
/**
|
|
832
|
-
* Express a native Cursor write/edit as
|
|
1022
|
+
* Express a native Cursor write/edit as a patch call when the host swapped the
|
|
833
1023
|
* edit-tool family out from under us.
|
|
834
1024
|
*
|
|
835
|
-
* OpenCode
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
* using its native write/edit
|
|
839
|
-
*
|
|
840
|
-
*
|
|
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.
|
|
841
1031
|
*
|
|
842
1032
|
* Keyed purely off the advertised set: this is inert whenever the host offers
|
|
843
1033
|
* `write`/`edit` normally, and equally inert when it offers neither those nor
|
|
844
|
-
*
|
|
1034
|
+
* a patch tool (the caller's unavailable-tool rejection still applies).
|
|
845
1035
|
*/
|
|
846
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
|
+
}
|
|
847
1043
|
if (parsed.toolName !== "write" && parsed.toolName !== "edit")
|
|
848
1044
|
return;
|
|
849
|
-
//
|
|
1045
|
+
// Patch envelopes are text. A binary write has no patch form at all,
|
|
850
1046
|
// so it must stay a write and reach the byte-preserving path — converting it
|
|
851
1047
|
// here would drop the bytes and report a patch-translation failure instead of
|
|
852
1048
|
// the image Cursor asked to save.
|
|
853
1049
|
if (binaryWritePayload(parsed))
|
|
854
1050
|
return;
|
|
855
|
-
const
|
|
856
|
-
if (advertised.has(parsed.toolName) || !
|
|
1051
|
+
const patchTool = hostPatchTool(advertised);
|
|
1052
|
+
if (advertised.has(parsed.toolName) || !patchTool)
|
|
857
1053
|
return;
|
|
858
1054
|
const requested = parsed.toolName;
|
|
859
1055
|
const filePath = opencodePathArg(parsed.args);
|
|
860
1056
|
const refuse = (reason) => {
|
|
861
|
-
parsed.toolName =
|
|
1057
|
+
parsed.toolName = patchTool;
|
|
862
1058
|
parsed.args = {};
|
|
863
1059
|
parsed.localError =
|
|
864
|
-
`Cursor ${requested} request cannot be expressed as
|
|
865
|
-
|
|
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.`;
|
|
866
1062
|
};
|
|
867
1063
|
if (!filePath) {
|
|
868
1064
|
refuse("no target path was provided");
|
|
869
1065
|
return;
|
|
870
1066
|
}
|
|
871
1067
|
let patchText;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
// `*** Add File:` overwrites an existing target, so a whole-file write maps
|
|
879
|
-
// directly without reading the current contents or diffing.
|
|
880
|
-
patchText = buildAddFilePatch(filePath, content);
|
|
881
|
-
}
|
|
882
|
-
else {
|
|
883
|
-
const oldString = stringValue(parsed.args.oldString);
|
|
884
|
-
const newString = stringValue(parsed.args.newString);
|
|
885
|
-
if (oldString === undefined || newString === undefined) {
|
|
886
|
-
refuse("the replacement is missing its old or new text");
|
|
887
|
-
return;
|
|
888
|
-
}
|
|
889
|
-
// apply_patch matches whole lines, so a substring edit has to be widened to
|
|
890
|
-
// the lines it touches — which requires the file the model is editing.
|
|
891
|
-
const absolute = path.resolve(workspaceRoot ?? process.cwd(), filePath);
|
|
892
|
-
let source;
|
|
893
|
-
try {
|
|
894
|
-
// Bounded because this read is synchronous on the Run pump. Cursor's own
|
|
895
|
-
// executor uses the same 50 MB threshold before reading a file it is
|
|
896
|
-
// about to write.
|
|
897
|
-
const size = fs.statSync(absolute).size;
|
|
898
|
-
if (size > MAX_EDIT_SOURCE_BYTES) {
|
|
899
|
-
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");
|
|
900
1073
|
return;
|
|
901
1074
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
refuse(`the target file could not be read (${e.message})`);
|
|
906
|
-
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);
|
|
907
1078
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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);
|
|
912
1128
|
}
|
|
913
|
-
patchText = buildUpdateFilePatch(filePath, plan.chunks);
|
|
914
1129
|
}
|
|
915
|
-
|
|
1130
|
+
catch (error) {
|
|
1131
|
+
refuse(error instanceof Error ? error.message : String(error));
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
parsed.toolName = patchTool;
|
|
916
1135
|
parsed.args = { patchText };
|
|
917
|
-
//
|
|
1136
|
+
// Patch output does not carry opencode `write`'s <path> tag, so keep
|
|
918
1137
|
// the requested path for the typed Cursor result.
|
|
919
1138
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
920
1139
|
}
|
|
@@ -957,7 +1176,7 @@ export function rejectPartialReadMutation(parsed) {
|
|
|
957
1176
|
if (parsed.toolName === "write") {
|
|
958
1177
|
content = parsed.args.content;
|
|
959
1178
|
}
|
|
960
|
-
else if (parsed.toolName
|
|
1179
|
+
else if (isHostPatchTool(parsed.toolName)) {
|
|
961
1180
|
const patchText = parsed.args.patchText;
|
|
962
1181
|
if (typeof patchText !== "string" || !patchText.includes("*** Add File:"))
|
|
963
1182
|
return;
|
|
@@ -1026,6 +1245,7 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1026
1245
|
background_shell_spawn: true,
|
|
1027
1246
|
command: command ?? "",
|
|
1028
1247
|
working_directory: workingDirectory,
|
|
1248
|
+
...(str(raw.description) ? { description: str(raw.description) } : {}),
|
|
1029
1249
|
},
|
|
1030
1250
|
localError: raw.enable_write_shell_stdin_tool === true
|
|
1031
1251
|
? "Interactive background shells are not available through OpenCode's bash tool."
|
|
@@ -1125,7 +1345,9 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1125
1345
|
? writeRequestResultMetadata(rawArgs, mapped.args)
|
|
1126
1346
|
: execVariant === "grep_args"
|
|
1127
1347
|
? grepRequestResultMetadata(rawArgs)
|
|
1128
|
-
:
|
|
1348
|
+
: execVariant === "delete_args" || execVariant === "ls_args"
|
|
1349
|
+
? { path: str(rawArgs.path) ?? "" }
|
|
1350
|
+
: undefined;
|
|
1129
1351
|
if (resultMetadata
|
|
1130
1352
|
&& resultMetadata.timeout_behavior !== 2
|
|
1131
1353
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1168,6 +1390,7 @@ function shellStreamResultMetadata(raw) {
|
|
|
1168
1390
|
return {
|
|
1169
1391
|
shell_stream: true,
|
|
1170
1392
|
command: str(raw.command) ?? "",
|
|
1393
|
+
...(str(raw.description) ? { description: str(raw.description) } : {}),
|
|
1171
1394
|
working_directory: str(raw.working_directory) ?? "",
|
|
1172
1395
|
timeout_ms: effectiveTimeout,
|
|
1173
1396
|
timeout_behavior: timeoutBehavior,
|
|
@@ -1289,6 +1512,10 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1289
1512
|
const timeout = num(cleaned.timeout);
|
|
1290
1513
|
if (timeout !== undefined)
|
|
1291
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;
|
|
1292
1519
|
return { toolName: dialect.shellTool, args };
|
|
1293
1520
|
}
|
|
1294
1521
|
case "grep": {
|
|
@@ -1573,6 +1800,9 @@ export function classifyMissingReadTarget(absolutePath) {
|
|
|
1573
1800
|
}
|
|
1574
1801
|
return undefined;
|
|
1575
1802
|
}
|
|
1803
|
+
return classifyReadTargetStat(absolutePath, stat);
|
|
1804
|
+
}
|
|
1805
|
+
function classifyReadTargetStat(absolutePath, stat) {
|
|
1576
1806
|
if (stat.isDirectory()) {
|
|
1577
1807
|
return { invalid_file: { path: absolutePath, reason: "Path is a directory, not a file" } };
|
|
1578
1808
|
}
|
|
@@ -1627,20 +1857,28 @@ function mcpImageItems(images) {
|
|
|
1627
1857
|
* shell execs hang on heartbeats forever).
|
|
1628
1858
|
*/
|
|
1629
1859
|
export function buildExecClientMessages(input) {
|
|
1860
|
+
if (input.error === "")
|
|
1861
|
+
return buildExecClientMessages({ ...input, error: "OpenCode tool execution failed" });
|
|
1630
1862
|
const resultField = input.resultField || "mcp_result";
|
|
1631
1863
|
const frames = [];
|
|
1632
|
-
|
|
1633
|
-
|
|
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") {
|
|
1634
1869
|
// Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
|
|
1635
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
|
+
}
|
|
1636
1876
|
if (input.error) {
|
|
1637
1877
|
frames.push(encodeShellStream(input.execId, undefined, { stderr: { data: input.error } }));
|
|
1638
|
-
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 } }));
|
|
1639
1880
|
}
|
|
1640
1881
|
else {
|
|
1641
|
-
if (stdout) {
|
|
1642
|
-
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
|
|
1643
|
-
}
|
|
1644
1882
|
if (input.shellOutcome?.kind === "backgrounded") {
|
|
1645
1883
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
1646
1884
|
backgrounded: {
|
|
@@ -1659,6 +1897,11 @@ export function buildExecClientMessages(input) {
|
|
|
1659
1897
|
exit: { code: 0, aborted: true, abort_reason: 2 },
|
|
1660
1898
|
}));
|
|
1661
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
|
+
}
|
|
1662
1905
|
else {
|
|
1663
1906
|
const exitCode = input.shellOutcome?.kind === "exit"
|
|
1664
1907
|
? Math.max(0, Math.min(0xffff_ffff, input.shellOutcome.code))
|
|
@@ -1674,7 +1917,14 @@ export function buildExecClientMessages(input) {
|
|
|
1674
1917
|
id: input.execId,
|
|
1675
1918
|
local_execution_time_ms: input.executionTimeMs ?? 0,
|
|
1676
1919
|
};
|
|
1677
|
-
clientMsg[resultField] = buildTypedExecResult(resultField, input.output, input.error, input.toolName, input.resultMetadata, input.shellOutcome, input.workspaceRoot, execResultImages(resultField, input.images));
|
|
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
|
+
}
|
|
1678
1928
|
frames.push(encodeMessage("AgentClientMessage", {
|
|
1679
1929
|
exec_client_message: clientMsg,
|
|
1680
1930
|
}));
|
|
@@ -1683,6 +1933,47 @@ export function buildExecClientMessages(input) {
|
|
|
1683
1933
|
frames.push(buildExecStreamClose(input.execId));
|
|
1684
1934
|
return frames;
|
|
1685
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
|
+
}
|
|
1686
1977
|
/** ACM #5 exec_client_control_message { stream_close { id } }. */
|
|
1687
1978
|
export function buildExecStreamClose(execId) {
|
|
1688
1979
|
return encodeMessage("AgentClientMessage", {
|
|
@@ -1691,6 +1982,38 @@ export function buildExecStreamClose(execId) {
|
|
|
1691
1982
|
},
|
|
1692
1983
|
});
|
|
1693
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
|
+
}
|
|
1694
2017
|
export function buildUnsupportedExecDeny(input) {
|
|
1695
2018
|
const { execId, variant, reason } = input;
|
|
1696
2019
|
const resultName = variant.resultName;
|
|
@@ -2086,21 +2409,6 @@ function splitForeignAbsolute(filePath, sep) {
|
|
|
2086
2409
|
frozen: 0,
|
|
2087
2410
|
};
|
|
2088
2411
|
}
|
|
2089
|
-
function isRelativePathToken(token) {
|
|
2090
|
-
if (!token || isAbsoluteToolPath(token))
|
|
2091
|
-
return false;
|
|
2092
|
-
// A slash alone does not make arbitrary shell output a path. In particular,
|
|
2093
|
-
// compact JSON, quoted strings, package ids, and shell syntax must remain
|
|
2094
|
-
// byte-for-byte model-visible rather than being prefixed with the workspace.
|
|
2095
|
-
if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
|
|
2096
|
-
return false;
|
|
2097
|
-
if (token.startsWith("./")
|
|
2098
|
-
|| token.startsWith("../")
|
|
2099
|
-
|| token.startsWith(".\\")
|
|
2100
|
-
|| token.startsWith("..\\"))
|
|
2101
|
-
return true;
|
|
2102
|
-
return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
|
|
2103
|
-
}
|
|
2104
2412
|
function resolveListedEntry(directory, entry) {
|
|
2105
2413
|
if (!entry || isAbsoluteToolPath(entry))
|
|
2106
2414
|
return entry;
|
|
@@ -2316,46 +2624,6 @@ function rewriteSearchPathLine(line, workspaceRoot) {
|
|
|
2316
2624
|
return line;
|
|
2317
2625
|
return resolveToolPath(line, workspaceRoot);
|
|
2318
2626
|
}
|
|
2319
|
-
/**
|
|
2320
|
-
* Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
|
|
2321
|
-
* paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
|
|
2322
|
-
* status words alone.
|
|
2323
|
-
*/
|
|
2324
|
-
function groundShellPathText(output, root) {
|
|
2325
|
-
if (!root || !output)
|
|
2326
|
-
return output;
|
|
2327
|
-
return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
|
|
2328
|
-
}
|
|
2329
|
-
function shellPathRoot(resultMetadata, workspaceRoot) {
|
|
2330
|
-
const workingDirectory = str(resultMetadata?.working_directory)?.trim();
|
|
2331
|
-
if (!workingDirectory)
|
|
2332
|
-
return workspaceRoot;
|
|
2333
|
-
if (isAbsoluteToolPath(workingDirectory))
|
|
2334
|
-
return workingDirectory;
|
|
2335
|
-
return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
|
|
2336
|
-
}
|
|
2337
|
-
function rewriteShellPathLine(line, root) {
|
|
2338
|
-
if (!line || line.startsWith(" ") || line.startsWith("\t"))
|
|
2339
|
-
return line;
|
|
2340
|
-
const trimmed = line.trimEnd();
|
|
2341
|
-
if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
2342
|
-
return line;
|
|
2343
|
-
}
|
|
2344
|
-
const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
|
|
2345
|
-
if (located && located[1] && isRelativePathToken(located[1])) {
|
|
2346
|
-
const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
|
|
2347
|
-
return `${resolveToolPath(located[1], root)}${suffix}`;
|
|
2348
|
-
}
|
|
2349
|
-
if (/\s/.test(trimmed))
|
|
2350
|
-
return line;
|
|
2351
|
-
const header = /^(.*):$/.exec(trimmed);
|
|
2352
|
-
if (header && header[1] && isRelativePathToken(header[1])) {
|
|
2353
|
-
return `${resolveToolPath(header[1], root)}:`;
|
|
2354
|
-
}
|
|
2355
|
-
if (isRelativePathToken(trimmed))
|
|
2356
|
-
return resolveToolPath(trimmed, root);
|
|
2357
|
-
return line;
|
|
2358
|
-
}
|
|
2359
2627
|
const OPENCODE_GREP_LINE = /^[ \t]+Line (\d+):[ \t]?(.*)$/;
|
|
2360
2628
|
/**
|
|
2361
2629
|
* OpenCode 1.x and 2.0 both render grep as `Found N matches`, a `path:` header,
|
|
@@ -2538,12 +2806,36 @@ export function unwrapReadOutput(output) {
|
|
|
2538
2806
|
// than the envelope (the envelope is exactly what Cursor echoes into writes).
|
|
2539
2807
|
return raw.join("\n");
|
|
2540
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
|
+
}
|
|
2541
2831
|
/**
|
|
2542
2832
|
* Map OpenCode tool text into the agent.v1 result oneof for each exec variant.
|
|
2543
2833
|
* OpenCode returns free-form text; we wrap it in the minimal success shape the
|
|
2544
2834
|
* server accepts (verified against agent.v1 wire captures).
|
|
2545
2835
|
*/
|
|
2546
2836
|
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot, images = []) {
|
|
2837
|
+
if (error === "")
|
|
2838
|
+
error = "OpenCode tool execution failed";
|
|
2547
2839
|
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
2548
2840
|
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
2549
2841
|
const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
|
|
@@ -2664,7 +2956,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2664
2956
|
};
|
|
2665
2957
|
}
|
|
2666
2958
|
case "write_result": {
|
|
2667
|
-
// Prefer the path recorded from WriteArgs / a remapped
|
|
2959
|
+
// Prefer the path recorded from WriteArgs / a remapped patch write.
|
|
2668
2960
|
// Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
|
|
2669
2961
|
// spill copies WriteSuccess.path into the model-visible filePath.
|
|
2670
2962
|
const remappedPath = str(resultMetadata?.path);
|
|
@@ -2695,16 +2987,15 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2695
2987
|
case "shell_result": {
|
|
2696
2988
|
const command = str(resultMetadata?.command) ?? "";
|
|
2697
2989
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2698
|
-
|
|
2699
|
-
if (error) {
|
|
2990
|
+
if (error || shellOutcome?.kind === "aborted") {
|
|
2700
2991
|
return {
|
|
2701
2992
|
failure: {
|
|
2702
2993
|
command,
|
|
2703
2994
|
working_directory: workingDirectory,
|
|
2704
|
-
exit_code: 1,
|
|
2705
|
-
stdout:
|
|
2706
|
-
stderr: error,
|
|
2707
|
-
aborted:
|
|
2995
|
+
exit_code: shellOutcome?.kind === "aborted" ? 0 : 1,
|
|
2996
|
+
stdout: output || "",
|
|
2997
|
+
stderr: error || "Shell execution interrupted",
|
|
2998
|
+
aborted: shellOutcome?.kind === "aborted",
|
|
2708
2999
|
},
|
|
2709
3000
|
};
|
|
2710
3001
|
}
|
|
@@ -2723,7 +3014,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2723
3014
|
command: shellOutcome.command || command,
|
|
2724
3015
|
working_directory: shellOutcome.workingDirectory || workingDirectory,
|
|
2725
3016
|
exit_code: 0,
|
|
2726
|
-
stdout:
|
|
3017
|
+
stdout: output,
|
|
2727
3018
|
shell_id: shellOutcome.shellId,
|
|
2728
3019
|
pid: shellOutcome.pid,
|
|
2729
3020
|
ms_to_wait: shellOutcome.msToWait,
|
|
@@ -2741,14 +3032,11 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2741
3032
|
command,
|
|
2742
3033
|
working_directory: workingDirectory,
|
|
2743
3034
|
exit_code: exitCode,
|
|
2744
|
-
stdout:
|
|
3035
|
+
stdout: output,
|
|
2745
3036
|
},
|
|
2746
3037
|
};
|
|
2747
3038
|
}
|
|
2748
3039
|
case "pi_bash_result":
|
|
2749
|
-
if (error)
|
|
2750
|
-
return { error: { error } };
|
|
2751
|
-
return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
|
|
2752
3040
|
case "pi_edit_result":
|
|
2753
3041
|
if (error)
|
|
2754
3042
|
return { error: { error } };
|
|
@@ -2766,13 +3054,13 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2766
3054
|
}
|
|
2767
3055
|
case "delete_result":
|
|
2768
3056
|
if (error)
|
|
2769
|
-
return { error: { path: "", error } };
|
|
2770
|
-
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: "" } };
|
|
2771
3059
|
case "background_shell_spawn_result": {
|
|
2772
3060
|
const command = str(resultMetadata?.command) ?? "";
|
|
2773
3061
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2774
|
-
if (error)
|
|
2775
|
-
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" } };
|
|
2776
3064
|
// Prefer the structured outcome captured by the plugin after-hook; markers
|
|
2777
3065
|
// are already stripped from the stored/rendered OpenCode output by then.
|
|
2778
3066
|
if (shellOutcome?.kind === "backgrounded") {
|
|
@@ -2786,8 +3074,10 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2786
3074
|
};
|
|
2787
3075
|
}
|
|
2788
3076
|
// Fallback when no plugin hook ran: parse the private spawn marker inline.
|
|
2789
|
-
|
|
2790
|
-
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;
|
|
2791
3081
|
if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0xffff_ffff) {
|
|
2792
3082
|
return {
|
|
2793
3083
|
error: {
|
|
@@ -2799,7 +3089,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2799
3089
|
}
|
|
2800
3090
|
return {
|
|
2801
3091
|
success: {
|
|
2802
|
-
shell_id:
|
|
3092
|
+
shell_id: shellId,
|
|
2803
3093
|
command,
|
|
2804
3094
|
working_directory: workingDirectory,
|
|
2805
3095
|
pid,
|
|
@@ -2808,7 +3098,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2808
3098
|
}
|
|
2809
3099
|
case "ls_result": {
|
|
2810
3100
|
if (error)
|
|
2811
|
-
return { error: { path: "", error } };
|
|
3101
|
+
return { error: { path: resolveToolPath(str(resultMetadata?.path) ?? "", resultRoot), error } };
|
|
2812
3102
|
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2813
3103
|
const rootPath = listing?.directory || resultRoot || "";
|
|
2814
3104
|
const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
|