cursor-opencode-provider 0.6.8 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -181
- package/dist/compaction-marker.d.ts +3 -2
- package/dist/compaction-marker.js +3 -2
- package/dist/host-agent-mode.d.ts +32 -0
- package/dist/host-agent-mode.js +92 -0
- package/dist/language-model.d.ts +15 -1
- package/dist/language-model.js +190 -36
- package/dist/models.d.ts +12 -1
- package/dist/models.js +16 -6
- package/dist/opencode2/catalog.d.ts +46 -11
- package/dist/opencode2/catalog.js +60 -45
- package/dist/opencode2/todo-tools.d.ts +64 -0
- package/dist/opencode2/todo-tools.js +165 -0
- package/dist/opencode2/types.d.ts +138 -83
- package/dist/opencode2/types.js +8 -10
- package/dist/plan-execution-kickoff.d.ts +2 -0
- package/dist/plan-execution-kickoff.js +25 -1
- package/dist/plugin-opencode2.d.ts +4 -1
- package/dist/plugin-opencode2.js +335 -154
- package/dist/plugin.js +5 -1
- package/dist/protocol/conversation-persistence.d.ts +2 -0
- package/dist/protocol/conversation-persistence.js +19 -1
- package/dist/protocol/conversation-state.d.ts +4 -0
- package/dist/protocol/conversation-state.js +4 -0
- package/dist/protocol/switch-mode.d.ts +5 -4
- package/dist/protocol/switch-mode.js +12 -4
- package/dist/protocol/tool-call-bridge.js +1 -1
- package/dist/protocol/tools.d.ts +36 -11
- package/dist/protocol/tools.js +747 -117
- package/dist/protocol/workspace-grounding.d.ts +17 -1
- package/dist/protocol/workspace-grounding.js +34 -5
- package/dist/session.d.ts +7 -1
- package/dist/shared.d.ts +3 -1
- package/dist/shared.js +3 -1
- package/dist/shell-timeout.d.ts +13 -0
- package/dist/shell-timeout.js +38 -0
- package/dist/todo-store.d.ts +29 -0
- package/dist/todo-store.js +78 -0
- package/dist/web-tools.d.ts +13 -0
- package/dist/web-tools.js +83 -5
- package/package.json +6 -3
package/dist/protocol/tools.js
CHANGED
|
@@ -13,6 +13,67 @@ import { BACKGROUND_SHELL_MARKER, buildBackgroundShellCommand, } from "../shell-
|
|
|
13
13
|
// probe (ExecServerMessage #10 → ExecClientMessage #10). request/result share a
|
|
14
14
|
// field number for every exec variant, so this is also the result field.
|
|
15
15
|
export const REQUEST_CONTEXT_RESULT_FIELD = 10;
|
|
16
|
+
export const OPENCODE_1_TOOL_DIALECT = {
|
|
17
|
+
filePathKey: "filePath",
|
|
18
|
+
shellTool: "bash",
|
|
19
|
+
};
|
|
20
|
+
function jsonSchemaProperties(schema) {
|
|
21
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
22
|
+
return undefined;
|
|
23
|
+
const obj = schema;
|
|
24
|
+
if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
|
|
25
|
+
return obj.properties;
|
|
26
|
+
}
|
|
27
|
+
const nested = obj.jsonSchema;
|
|
28
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
29
|
+
const props = nested.properties;
|
|
30
|
+
if (props && typeof props === "object" && !Array.isArray(props)) {
|
|
31
|
+
return props;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
|
|
37
|
+
export function opencodePathArg(args) {
|
|
38
|
+
if (!args)
|
|
39
|
+
return undefined;
|
|
40
|
+
return str(args.filePath) ?? str(args.path) ?? str(args.file_path);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Infer the host tool dialect from advertised AI SDK schemas.
|
|
44
|
+
* OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
|
|
45
|
+
*/
|
|
46
|
+
export function hostToolDialectFromTools(tools) {
|
|
47
|
+
let filePathKey;
|
|
48
|
+
for (const name of ["read", "write", "edit"]) {
|
|
49
|
+
const tool = tools.find((candidate) => candidate.name === name);
|
|
50
|
+
const props = jsonSchemaProperties(tool?.inputSchema);
|
|
51
|
+
if (!props)
|
|
52
|
+
continue;
|
|
53
|
+
if ("path" in props && !("filePath" in props)) {
|
|
54
|
+
filePathKey = "path";
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if ("filePath" in props) {
|
|
58
|
+
filePathKey = "filePath";
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const names = new Set(tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
|
|
63
|
+
const shellTool = names.has("shell") && !names.has("bash") ? "shell" : "bash";
|
|
64
|
+
if (!filePathKey) {
|
|
65
|
+
// OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
|
|
66
|
+
filePathKey = shellTool === "shell" ? "path" : "filePath";
|
|
67
|
+
}
|
|
68
|
+
return { filePathKey, shellTool };
|
|
69
|
+
}
|
|
70
|
+
function assignHostFilePath(args, filePath, dialect) {
|
|
71
|
+
delete args.filePath;
|
|
72
|
+
delete args.path;
|
|
73
|
+
delete args.file_path;
|
|
74
|
+
if (filePath)
|
|
75
|
+
args[dialect.filePathKey] = filePath;
|
|
76
|
+
}
|
|
16
77
|
export const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
|
|
17
78
|
export const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
|
|
18
79
|
export const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
|
|
@@ -171,15 +232,17 @@ export function resolveCustomWebToolAlias(toolName, aliases) {
|
|
|
171
232
|
export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
|
|
172
233
|
if (tools.length === 0)
|
|
173
234
|
return [];
|
|
174
|
-
const order = [];
|
|
175
235
|
const byServer = new Map();
|
|
176
|
-
|
|
236
|
+
// OpenCode's tool catalog is a set even though the hook exposes it as an
|
|
237
|
+
// array. Keep both the server and per-server tool order canonical so a host
|
|
238
|
+
// that enumerates the same catalog differently does not invalidate Cursor's
|
|
239
|
+
// RequestContext prefix cache.
|
|
240
|
+
for (const t of [...tools].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
177
241
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
178
242
|
let list = byServer.get(id.server);
|
|
179
243
|
if (!list) {
|
|
180
244
|
list = [];
|
|
181
245
|
byServer.set(id.server, list);
|
|
182
|
-
order.push(id.server);
|
|
183
246
|
}
|
|
184
247
|
list.push({
|
|
185
248
|
tool_name: t.sourceName ? t.name : id.toolName,
|
|
@@ -187,7 +250,14 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
|
|
|
187
250
|
input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
|
|
188
251
|
});
|
|
189
252
|
}
|
|
190
|
-
|
|
253
|
+
const orderedServers = [...byServer.keys()].sort((left, right) => {
|
|
254
|
+
if (left === providerIdentifier)
|
|
255
|
+
return right === providerIdentifier ? 0 : -1;
|
|
256
|
+
if (right === providerIdentifier)
|
|
257
|
+
return 1;
|
|
258
|
+
return left.localeCompare(right);
|
|
259
|
+
});
|
|
260
|
+
return orderedServers.map((server) => ({
|
|
191
261
|
server_name: server,
|
|
192
262
|
server_identifier: server,
|
|
193
263
|
tools: byServer.get(server),
|
|
@@ -262,6 +332,7 @@ const opencodeToolToCursor = {
|
|
|
262
332
|
write: "write_args",
|
|
263
333
|
grep: "grep_args",
|
|
264
334
|
bash: "shell_stream_args",
|
|
335
|
+
shell: "shell_stream_args",
|
|
265
336
|
task: "subagent_args",
|
|
266
337
|
mcp: "mcp_args",
|
|
267
338
|
};
|
|
@@ -348,33 +419,54 @@ const CURSOR_SUBAGENT_TYPE_TO_OPENCODE = {
|
|
|
348
419
|
security_review: "explore",
|
|
349
420
|
};
|
|
350
421
|
const SUBAGENT_CATALOG_MARKER = "Available agent types and the tools they have access to:";
|
|
422
|
+
const SUBAGENT_INLINE_MARKER = "Available subagents:";
|
|
351
423
|
function parseSubagentDescriptionCatalog(description) {
|
|
352
424
|
if (!description)
|
|
353
425
|
return { found: false, agents: [] };
|
|
354
426
|
const marker = description.indexOf(SUBAGENT_CATALOG_MARKER);
|
|
355
|
-
if (marker
|
|
427
|
+
if (marker >= 0) {
|
|
428
|
+
const agents = [];
|
|
429
|
+
const lines = description.slice(marker + SUBAGENT_CATALOG_MARKER.length).split(/\r?\n/);
|
|
430
|
+
let started = false;
|
|
431
|
+
for (const line of lines) {
|
|
432
|
+
const match = line.match(/^\s*-\s+([^:]+):\s*(.*)$/);
|
|
433
|
+
if (!match) {
|
|
434
|
+
if (started && line.trim())
|
|
435
|
+
break;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
started = true;
|
|
439
|
+
const name = match[1].trim().replace(/^`|`$/g, "");
|
|
440
|
+
if (!name)
|
|
441
|
+
continue;
|
|
442
|
+
const agentDescription = match[2].trim();
|
|
443
|
+
agents.push({
|
|
444
|
+
name,
|
|
445
|
+
...(agentDescription ? { description: agentDescription } : {}),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
return { found: true, agents };
|
|
449
|
+
}
|
|
450
|
+
// OpenCode 2 `subagent` inlines the list after "Available subagents:".
|
|
451
|
+
const inlineAt = description.indexOf(SUBAGENT_INLINE_MARKER);
|
|
452
|
+
if (inlineAt < 0)
|
|
356
453
|
return { found: false, agents: [] };
|
|
454
|
+
const rest = description.slice(inlineAt + SUBAGENT_INLINE_MARKER.length);
|
|
357
455
|
const agents = [];
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
for (
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
started = true;
|
|
368
|
-
const name = match[1].trim().replace(/^`|`$/g, "");
|
|
369
|
-
if (!name)
|
|
370
|
-
continue;
|
|
371
|
-
const agentDescription = match[2].trim();
|
|
456
|
+
const matches = rest.matchAll(/-\s+([A-Za-z0-9_-]+):\s*/g);
|
|
457
|
+
const hits = [...matches];
|
|
458
|
+
for (let i = 0; i < hits.length; i++) {
|
|
459
|
+
const hit = hits[i];
|
|
460
|
+
const name = hit[1];
|
|
461
|
+
const start = (hit.index ?? 0) + hit[0].length;
|
|
462
|
+
const end = i + 1 < hits.length ? (hits[i + 1].index ?? rest.length) : rest.length;
|
|
463
|
+
const agentDescription = rest.slice(start, end).trim();
|
|
372
464
|
agents.push({
|
|
373
465
|
name,
|
|
374
466
|
...(agentDescription ? { description: agentDescription } : {}),
|
|
375
467
|
});
|
|
376
468
|
}
|
|
377
|
-
return { found:
|
|
469
|
+
return { found: agents.length > 0, agents };
|
|
378
470
|
}
|
|
379
471
|
function subagentTypeEnumValues(schema) {
|
|
380
472
|
const out = new Set();
|
|
@@ -391,7 +483,8 @@ function subagentTypeEnumValues(schema) {
|
|
|
391
483
|
return;
|
|
392
484
|
}
|
|
393
485
|
const record = value;
|
|
394
|
-
if (propertyName === "subagent_type"
|
|
486
|
+
if ((propertyName === "subagent_type" || propertyName === "agent")
|
|
487
|
+
&& Array.isArray(record.enum)) {
|
|
395
488
|
for (const item of record.enum) {
|
|
396
489
|
if (typeof item === "string" && item)
|
|
397
490
|
out.add(item);
|
|
@@ -405,16 +498,17 @@ function subagentTypeEnumValues(schema) {
|
|
|
405
498
|
}
|
|
406
499
|
/** Extract the current host's permission-filtered Task/Actor recipient catalog. */
|
|
407
500
|
export function extractHostSubagentCatalog(tools) {
|
|
408
|
-
const executorTool = tools.find((tool) => tool.name === "task")
|
|
501
|
+
const executorTool = tools.find((tool) => tool.name === "task")
|
|
502
|
+
?? tools.find((tool) => tool.name === "subagent");
|
|
409
503
|
if (!executorTool)
|
|
410
504
|
return { agents: [], complete: true };
|
|
411
505
|
const described = parseSubagentDescriptionCatalog(executorTool.description);
|
|
412
506
|
const enumNames = subagentTypeEnumValues(executorTool.inputSchema);
|
|
413
507
|
const descriptions = new Map(described.agents.map((agent) => [agent.name, agent.description]));
|
|
414
|
-
// A structured subagent_type enum is stricter than prose, so it is authoritative.
|
|
508
|
+
// A structured subagent_type/agent enum is stricter than prose, so it is authoritative.
|
|
415
509
|
const names = new Set(enumNames.length > 0 ? enumNames : described.agents.map((agent) => agent.name));
|
|
416
510
|
return {
|
|
417
|
-
executor: "task",
|
|
511
|
+
executor: executorTool.name === "subagent" ? "subagent" : "task",
|
|
418
512
|
agents: [...names].map((name) => ({
|
|
419
513
|
name,
|
|
420
514
|
...(descriptions.get(name) ? { description: descriptions.get(name) } : {}),
|
|
@@ -474,13 +568,19 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
474
568
|
if (parsed.resultField !== "subagent_result" || parsed.toolName !== "task")
|
|
475
569
|
return;
|
|
476
570
|
const advertised = new Set(advertisedToolNames);
|
|
477
|
-
|
|
571
|
+
const executor = advertised.has("task")
|
|
572
|
+
? "task"
|
|
573
|
+
: advertised.has("subagent")
|
|
574
|
+
? "subagent"
|
|
575
|
+
: undefined;
|
|
576
|
+
if (!executor)
|
|
478
577
|
return;
|
|
479
|
-
const executor = "task";
|
|
480
578
|
const description = str(parsed.args.description) ?? "";
|
|
481
579
|
const prompt = str(parsed.args.prompt) ?? "";
|
|
482
580
|
const cursorSubagentType = str(parsed.resultMetadata?.cursor_subagent_type) ??
|
|
483
|
-
str(parsed.args.subagent_type) ??
|
|
581
|
+
str(parsed.args.subagent_type) ??
|
|
582
|
+
str(parsed.args.agent) ??
|
|
583
|
+
"";
|
|
484
584
|
const subagentType = resolveCursorSubagentType(cursorSubagentType, catalog);
|
|
485
585
|
if (!subagentType) {
|
|
486
586
|
const available = catalog?.agents.map((agent) => agent.name).join(", ") || "none";
|
|
@@ -489,15 +589,23 @@ export function remapNativeSubagentForCatalog(parsed, advertisedToolNames, catal
|
|
|
489
589
|
`Available subagents: ${available}.`;
|
|
490
590
|
return;
|
|
491
591
|
}
|
|
492
|
-
const resumeAgentId = str(parsed.args.task_id);
|
|
592
|
+
const resumeAgentId = str(parsed.args.task_id) ?? str(parsed.args.sessionID);
|
|
493
593
|
parsed.toolName = executor;
|
|
494
|
-
parsed.args =
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
594
|
+
parsed.args = executor === "subagent"
|
|
595
|
+
? {
|
|
596
|
+
agent: subagentType,
|
|
597
|
+
description,
|
|
598
|
+
prompt,
|
|
599
|
+
...(resumeAgentId ? { sessionID: resumeAgentId } : {}),
|
|
600
|
+
...(parsed.args.background === true ? { background: true } : {}),
|
|
601
|
+
}
|
|
602
|
+
: {
|
|
603
|
+
description,
|
|
604
|
+
prompt,
|
|
605
|
+
subagent_type: subagentType,
|
|
606
|
+
...(resumeAgentId ? { task_id: resumeAgentId } : {}),
|
|
607
|
+
...(parsed.args.background === true ? { background: true } : {}),
|
|
608
|
+
};
|
|
501
609
|
}
|
|
502
610
|
/** Matches Cursor's own pre-write read threshold (`local-exec` 52428800). */
|
|
503
611
|
const MAX_EDIT_SOURCE_BYTES = 50 * 1024 * 1024;
|
|
@@ -625,7 +733,7 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
625
733
|
const advertised = new Set(advertisedToolNames);
|
|
626
734
|
if (!advertised.has("edit") && !advertised.has(APPLY_PATCH_TOOL))
|
|
627
735
|
return false;
|
|
628
|
-
const filePath =
|
|
736
|
+
const filePath = opencodePathArg(parsed.args);
|
|
629
737
|
const content = stringValue(parsed.args.content);
|
|
630
738
|
if (!filePath || content === undefined || !editPath)
|
|
631
739
|
return false;
|
|
@@ -651,8 +759,11 @@ export function remapCorrelatedEditWriteForCatalog(parsed, advertisedToolNames,
|
|
|
651
759
|
if (!replacement)
|
|
652
760
|
return false;
|
|
653
761
|
parsed.toolName = "edit";
|
|
762
|
+
const filePathKey = typeof parsed.args.path === "string" && typeof parsed.args.filePath !== "string"
|
|
763
|
+
? "path"
|
|
764
|
+
: "filePath";
|
|
654
765
|
parsed.args = {
|
|
655
|
-
filePath,
|
|
766
|
+
[filePathKey]: filePath,
|
|
656
767
|
oldString: replacement.oldString,
|
|
657
768
|
newString: replacement.newString,
|
|
658
769
|
};
|
|
@@ -687,7 +798,7 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
|
|
|
687
798
|
if (advertised.has(parsed.toolName) || !advertised.has(APPLY_PATCH_TOOL))
|
|
688
799
|
return;
|
|
689
800
|
const requested = parsed.toolName;
|
|
690
|
-
const filePath =
|
|
801
|
+
const filePath = opencodePathArg(parsed.args);
|
|
691
802
|
const refuse = (reason) => {
|
|
692
803
|
parsed.toolName = APPLY_PATCH_TOOL;
|
|
693
804
|
parsed.args = {};
|
|
@@ -749,6 +860,18 @@ export function remapEditToolsForCatalog(parsed, advertisedToolNames, workspaceR
|
|
|
749
860
|
// the requested path for the typed Cursor result.
|
|
750
861
|
parsed.resultMetadata = { ...parsed.resultMetadata, path: filePath };
|
|
751
862
|
}
|
|
863
|
+
function readRequestResultMetadata(raw, mappedArgs) {
|
|
864
|
+
const requestedPath = str(raw.path)
|
|
865
|
+
?? str(raw.filePath)
|
|
866
|
+
?? str(raw.file_path)
|
|
867
|
+
?? opencodePathArg(mappedArgs)
|
|
868
|
+
?? "";
|
|
869
|
+
return {
|
|
870
|
+
path: requestedPath,
|
|
871
|
+
...(typeof mappedArgs.offset === "number" ? { offset: mappedArgs.offset } : {}),
|
|
872
|
+
...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
|
|
873
|
+
};
|
|
874
|
+
}
|
|
752
875
|
/**
|
|
753
876
|
* A partial-read notice is for Cursor's model, never for a file. Refuse only
|
|
754
877
|
* whole-file mutation forms that echo it. A targeted edit or Update File patch
|
|
@@ -775,18 +898,19 @@ export function rejectPartialReadMutation(parsed) {
|
|
|
775
898
|
|| !content.includes("[Partial read:")
|
|
776
899
|
|| !content.includes("It is NOT the complete file."))
|
|
777
900
|
return;
|
|
778
|
-
const filePath =
|
|
779
|
-
? parsed.args.filePath
|
|
780
|
-
: "the target file";
|
|
901
|
+
const filePath = opencodePathArg(parsed.args) ?? "the target file";
|
|
781
902
|
const nextOffset = /Continue with offset=(\d+)/.exec(content)?.[1];
|
|
903
|
+
const longLine = content.includes("Use a byte-preserving read method");
|
|
782
904
|
parsed.localError =
|
|
783
905
|
`NO FILE CHANGE WAS MADE. Refusing a whole-file mutation of ${JSON.stringify(filePath)} ` +
|
|
784
906
|
"because it contains the provider's partial-read notice. Do not retry the same mutation. " +
|
|
785
907
|
(nextOffset
|
|
786
908
|
? `Read the file from offset=${nextOffset}, then use a targeted edit or Update File patch.`
|
|
787
|
-
:
|
|
909
|
+
: longLine
|
|
910
|
+
? "Inspect the full line with a byte-preserving read method, then use a targeted edit or Update File patch."
|
|
911
|
+
: "Read the remaining file ranges, then use a targeted edit or Update File patch.");
|
|
788
912
|
}
|
|
789
|
-
export function parseExecServerMessage(msg) {
|
|
913
|
+
export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
790
914
|
const id = msg.id;
|
|
791
915
|
if (id === undefined)
|
|
792
916
|
return undefined;
|
|
@@ -823,7 +947,7 @@ export function parseExecServerMessage(msg) {
|
|
|
823
947
|
return {
|
|
824
948
|
id,
|
|
825
949
|
execId,
|
|
826
|
-
toolName:
|
|
950
|
+
toolName: dialect.shellTool,
|
|
827
951
|
args,
|
|
828
952
|
resultField,
|
|
829
953
|
resultMetadata: {
|
|
@@ -877,13 +1001,17 @@ export function parseExecServerMessage(msg) {
|
|
|
877
1001
|
// tool name (Cursor's model may have shortened "opencode-read" → "read") and
|
|
878
1002
|
// decode the argument map back into JSON for opencode to execute.
|
|
879
1003
|
const m = msg.mcp_args ?? {};
|
|
880
|
-
const
|
|
1004
|
+
const rawArgs = decodeMcpArgs(m.args);
|
|
1005
|
+
const mapped = mapCursorArgsToOpencode(mcpRealToolName(m), rawArgs, "mcp_args", dialect);
|
|
881
1006
|
return {
|
|
882
1007
|
id,
|
|
883
1008
|
execId,
|
|
884
1009
|
toolName: mapped.toolName,
|
|
885
1010
|
args: mapped.args,
|
|
886
1011
|
resultField,
|
|
1012
|
+
...(mapped.toolName === "read"
|
|
1013
|
+
? { resultMetadata: readRequestResultMetadata(rawArgs, mapped.args) }
|
|
1014
|
+
: {}),
|
|
887
1015
|
};
|
|
888
1016
|
}
|
|
889
1017
|
if (execVariant === "pi_edit_args") {
|
|
@@ -896,8 +1024,7 @@ export function parseExecServerMessage(msg) {
|
|
|
896
1024
|
const oldString = replacement ? stringValue(replacement.old_text) : undefined;
|
|
897
1025
|
const newString = replacement ? stringValue(replacement.new_text) : undefined;
|
|
898
1026
|
const args = {};
|
|
899
|
-
|
|
900
|
-
args.filePath = path;
|
|
1027
|
+
assignHostFilePath(args, path, dialect);
|
|
901
1028
|
if (oldString !== undefined)
|
|
902
1029
|
args.oldString = oldString;
|
|
903
1030
|
if (newString !== undefined)
|
|
@@ -916,16 +1043,12 @@ export function parseExecServerMessage(msg) {
|
|
|
916
1043
|
const toolName = cursorToolToOpencode[execVariant];
|
|
917
1044
|
if (!toolName)
|
|
918
1045
|
return undefined;
|
|
919
|
-
const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant);
|
|
1046
|
+
const mapped = mapCursorArgsToOpencode(toolName, msg[execVariant] ?? {}, execVariant, dialect);
|
|
920
1047
|
const rawArgs = msg[execVariant] ?? {};
|
|
921
1048
|
const resultMetadata = execVariant === "shell_stream_args" || execVariant === "shell_args"
|
|
922
1049
|
? shellStreamResultMetadata(rawArgs)
|
|
923
|
-
: execVariant === "read_args"
|
|
924
|
-
?
|
|
925
|
-
path: str(rawArgs.path) ?? str(rawArgs.file_path) ?? "",
|
|
926
|
-
...(typeof mapped.args.offset === "number" ? { offset: mapped.args.offset } : {}),
|
|
927
|
-
...(typeof mapped.args.limit === "number" ? { limit: mapped.args.limit } : {}),
|
|
928
|
-
}
|
|
1050
|
+
: execVariant === "read_args" || execVariant === "pi_read_args"
|
|
1051
|
+
? readRequestResultMetadata(rawArgs, mapped.args)
|
|
929
1052
|
: undefined;
|
|
930
1053
|
if (resultMetadata
|
|
931
1054
|
&& resultMetadata.timeout_behavior !== 2
|
|
@@ -980,7 +1103,7 @@ function shellStreamResultMetadata(raw) {
|
|
|
980
1103
|
* Without this, OpenCode rejects calls with InvalidArgumentsError
|
|
981
1104
|
* (e.g. `path` instead of `filePath`, `file_text` instead of `content`).
|
|
982
1105
|
*/
|
|
983
|
-
export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
1106
|
+
export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
984
1107
|
const cleaned = {};
|
|
985
1108
|
for (const [k, v] of Object.entries(raw)) {
|
|
986
1109
|
if (v === undefined || v === null)
|
|
@@ -996,7 +1119,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
996
1119
|
// Native ls_args → OpenCode read (directory listing via read).
|
|
997
1120
|
if (execVariant === "ls_args") {
|
|
998
1121
|
const filePath = str(cleaned.path) ?? str(cleaned.filePath);
|
|
999
|
-
return { toolName: "read", args: filePath ? { filePath } : {} };
|
|
1122
|
+
return { toolName: "read", args: filePath ? { [dialect.filePathKey]: filePath } : {} };
|
|
1000
1123
|
}
|
|
1001
1124
|
// F11: Cursor native delete_args → OpenCode bash. No delete builtin exists, so
|
|
1002
1125
|
// we emulate with `rm -f -- <path>` (shell-quoted). Same permission boundary
|
|
@@ -1004,7 +1127,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1004
1127
|
if (execVariant === "delete_args") {
|
|
1005
1128
|
const target = str(cleaned.path) ?? str(cleaned.filePath);
|
|
1006
1129
|
return {
|
|
1007
|
-
toolName:
|
|
1130
|
+
toolName: dialect.shellTool,
|
|
1008
1131
|
args: target ? { command: `rm -f -- ${shellQuote(target)}` } : { command: "true" },
|
|
1009
1132
|
};
|
|
1010
1133
|
}
|
|
@@ -1012,8 +1135,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1012
1135
|
case "read": {
|
|
1013
1136
|
const args = {};
|
|
1014
1137
|
const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
|
|
1015
|
-
|
|
1016
|
-
args.filePath = filePath;
|
|
1138
|
+
assignHostFilePath(args, filePath, dialect);
|
|
1017
1139
|
// Cursor often sends offset=0/limit=0 as protobuf defaults. OpenCode's
|
|
1018
1140
|
// read treats limit=0 as "read zero lines" (empty content) — omit zeros.
|
|
1019
1141
|
const offset = num(cleaned.offset);
|
|
@@ -1027,8 +1149,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1027
1149
|
case "write": {
|
|
1028
1150
|
const args = {};
|
|
1029
1151
|
const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
|
|
1030
|
-
|
|
1031
|
-
args.filePath = filePath;
|
|
1152
|
+
assignHostFilePath(args, filePath, dialect);
|
|
1032
1153
|
// Cursor's LocalWriteExecutor prefers `file_bytes` whenever it is
|
|
1033
1154
|
// non-empty and only falls back to `file_text`; mirror that order so a
|
|
1034
1155
|
// byte-encoded write is not silently seen as empty content.
|
|
@@ -1060,8 +1181,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1060
1181
|
}
|
|
1061
1182
|
const args = {};
|
|
1062
1183
|
const filePath = str(cleaned.filePath) ?? str(cleaned.path) ?? str(cleaned.file_path);
|
|
1063
|
-
|
|
1064
|
-
args.filePath = filePath;
|
|
1184
|
+
assignHostFilePath(args, filePath, dialect);
|
|
1065
1185
|
const oldString = stringValue(cleaned.oldString) ?? stringValue(cleaned.old_string);
|
|
1066
1186
|
if (oldString !== undefined)
|
|
1067
1187
|
args.oldString = oldString;
|
|
@@ -1072,7 +1192,8 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1072
1192
|
args.replaceAll = cleaned.replaceAll;
|
|
1073
1193
|
return { toolName: "edit", args };
|
|
1074
1194
|
}
|
|
1075
|
-
case "bash":
|
|
1195
|
+
case "bash":
|
|
1196
|
+
case "shell": {
|
|
1076
1197
|
const args = {};
|
|
1077
1198
|
const command = str(cleaned.command);
|
|
1078
1199
|
if (command)
|
|
@@ -1083,7 +1204,7 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant) {
|
|
|
1083
1204
|
const timeout = num(cleaned.timeout);
|
|
1084
1205
|
if (timeout !== undefined)
|
|
1085
1206
|
args.timeout = timeout;
|
|
1086
|
-
return { toolName:
|
|
1207
|
+
return { toolName: dialect.shellTool, args };
|
|
1087
1208
|
}
|
|
1088
1209
|
case "grep": {
|
|
1089
1210
|
const pattern = str(cleaned.pattern);
|
|
@@ -1193,12 +1314,33 @@ function num(v) {
|
|
|
1193
1314
|
return Number(v);
|
|
1194
1315
|
return undefined;
|
|
1195
1316
|
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Fallback OpenCode task title when Cursor's SubagentArgs has no description.
|
|
1319
|
+
* Prefer {@link preferCorrelatedTaskDescription} when a TaskToolCall is available —
|
|
1320
|
+
* that carries Cursor's real 3–5 word title. This only slices the prompt.
|
|
1321
|
+
*/
|
|
1196
1322
|
function describeSubagentTask(prompt, subagentType) {
|
|
1197
1323
|
const words = prompt?.replace(/\s+/g, " ").trim().split(" ").filter(Boolean).slice(0, 5);
|
|
1198
1324
|
if (words?.length)
|
|
1199
1325
|
return words.join(" ");
|
|
1200
1326
|
return `${subagentType || "Delegated"} task`;
|
|
1201
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Replace the prompt-derived task title with Cursor's TaskToolCall description
|
|
1330
|
+
* when the display call is correlated via tool_call_id. SubagentArgs itself has
|
|
1331
|
+
* no description field, so without this OpenCode shows a cut-off first-five-words
|
|
1332
|
+
* slice of the prompt.
|
|
1333
|
+
*/
|
|
1334
|
+
export function preferCorrelatedTaskDescription(parsed, description) {
|
|
1335
|
+
if (parsed.toolName !== "task" && parsed.toolName !== "subagent")
|
|
1336
|
+
return;
|
|
1337
|
+
if (parsed.resultField !== "subagent_result")
|
|
1338
|
+
return;
|
|
1339
|
+
const trimmed = description?.trim();
|
|
1340
|
+
if (!trimmed)
|
|
1341
|
+
return;
|
|
1342
|
+
parsed.args.description = trimmed;
|
|
1343
|
+
}
|
|
1202
1344
|
function shellQuote(s) {
|
|
1203
1345
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1204
1346
|
}
|
|
@@ -1288,9 +1430,11 @@ export function isUriReadTarget(requested) {
|
|
|
1288
1430
|
*/
|
|
1289
1431
|
export function resolveReadTargetPath(requested, workspaceRoot) {
|
|
1290
1432
|
const expanded = untildify(requested);
|
|
1291
|
-
if (
|
|
1292
|
-
return path.resolve(
|
|
1433
|
+
if (isAbsoluteToolPath(expanded)) {
|
|
1434
|
+
return isForeignAbsoluteToolPath(expanded) ? expanded : path.resolve(expanded);
|
|
1293
1435
|
}
|
|
1436
|
+
if (workspaceRoot)
|
|
1437
|
+
return joinToolPath(workspaceRoot, expanded);
|
|
1294
1438
|
return path.resolve(expanded);
|
|
1295
1439
|
}
|
|
1296
1440
|
/**
|
|
@@ -1355,6 +1499,7 @@ export function buildExecClientMessages(input) {
|
|
|
1355
1499
|
const resultField = input.resultField || "mcp_result";
|
|
1356
1500
|
const frames = [];
|
|
1357
1501
|
if (resultField === "shell_stream") {
|
|
1502
|
+
const stdout = groundShellPathText(input.output, shellPathRoot(input.resultMetadata, input.workspaceRoot));
|
|
1358
1503
|
// Real clients always emit Start → Stdout/Stderr* → Exit (capture/tests).
|
|
1359
1504
|
frames.push(encodeShellStream(input.execId, undefined, { start: {} }));
|
|
1360
1505
|
if (input.error) {
|
|
@@ -1362,8 +1507,8 @@ export function buildExecClientMessages(input) {
|
|
|
1362
1507
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, { exit: { code: 1, aborted: false } }));
|
|
1363
1508
|
}
|
|
1364
1509
|
else {
|
|
1365
|
-
if (
|
|
1366
|
-
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data:
|
|
1510
|
+
if (stdout) {
|
|
1511
|
+
frames.push(encodeShellStream(input.execId, undefined, { stdout: { data: stdout } }));
|
|
1367
1512
|
}
|
|
1368
1513
|
if (input.shellOutcome?.kind === "backgrounded") {
|
|
1369
1514
|
frames.push(encodeShellStream(input.execId, input.executionTimeMs, {
|
|
@@ -1573,17 +1718,396 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1573
1718
|
frames.push(buildExecStreamClose(execId));
|
|
1574
1719
|
return frames;
|
|
1575
1720
|
}
|
|
1721
|
+
/** OpenCode 2 `read` prints this when a text page does not reach EOF. */
|
|
1722
|
+
const OPENCODE2_READ_TRUNCATION = /^\[Output truncated\. Continue reading with offset:\s*(\d+)\]\s*$/;
|
|
1723
|
+
/** OpenCode 2 stops a read at 2,000 lines as well as 50 KB. */
|
|
1724
|
+
const OPENCODE2_READ_MAX_LINES = 2_000;
|
|
1725
|
+
const OPENCODE2_READ_MAX_LINE_CHARS = 2_000;
|
|
1726
|
+
const OPENCODE2_READ_LINE_TRUNCATION = `... (line truncated to ${OPENCODE2_READ_MAX_LINE_CHARS} chars)`;
|
|
1727
|
+
/**
|
|
1728
|
+
* `read-filesystem.ts` shortens each selected line to 2,000 UTF-16 code units.
|
|
1729
|
+
* One code unit encodes to at most three UTF-8 bytes. Add the 34-byte
|
|
1730
|
+
* `... (line truncated to 2000 chars)` suffix and one line separator when
|
|
1731
|
+
* proving a page was close enough for the next line not to fit.
|
|
1732
|
+
*/
|
|
1733
|
+
const OPENCODE2_MAX_RENDERED_LINE_BYTES = (OPENCODE2_READ_MAX_LINE_CHARS * 3) + Buffer.byteLength(OPENCODE2_READ_LINE_TRUNCATION, "utf8") + 1;
|
|
1734
|
+
function normalizeToolText(output) {
|
|
1735
|
+
return output.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* OpenCode 2 `ReadTool.toModelContent` for a file. The whole string must match;
|
|
1739
|
+
* a file that merely quotes the header stays a numbered body line.
|
|
1740
|
+
*
|
|
1741
|
+
* Read file <path>, lines <start>-<end>
|
|
1742
|
+
* <n>: <line>
|
|
1743
|
+
* [Output truncated. Continue reading with offset: <next>]
|
|
1744
|
+
*/
|
|
1745
|
+
function parseOpenCode2FileRead(output, resultMetadata) {
|
|
1746
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1747
|
+
if (!normalized.startsWith("Read file "))
|
|
1748
|
+
return undefined;
|
|
1749
|
+
const lines = normalized.split("\n");
|
|
1750
|
+
const headerLine = (lines[0] ?? "").trim();
|
|
1751
|
+
const empty = /^Read file (.*), 0 lines$/.exec(headerLine);
|
|
1752
|
+
if (empty) {
|
|
1753
|
+
if (lines.length !== 1)
|
|
1754
|
+
return undefined;
|
|
1755
|
+
return {
|
|
1756
|
+
path: empty[1] ?? "",
|
|
1757
|
+
content: "",
|
|
1758
|
+
totalLines: 0,
|
|
1759
|
+
hostTruncated: false,
|
|
1760
|
+
outputCapped: false,
|
|
1761
|
+
truncatedLines: [],
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
const header = /^Read file (.*), lines (\d+)-(\d+)$/.exec(headerLine);
|
|
1765
|
+
if (!header)
|
|
1766
|
+
return undefined;
|
|
1767
|
+
const start = Number(header[2]);
|
|
1768
|
+
const end = Number(header[3]);
|
|
1769
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start) {
|
|
1770
|
+
return undefined;
|
|
1771
|
+
}
|
|
1772
|
+
let bodyEnd = lines.length;
|
|
1773
|
+
let nextOffset;
|
|
1774
|
+
const banner = OPENCODE2_READ_TRUNCATION.exec(lines[lines.length - 1] ?? "");
|
|
1775
|
+
if (banner) {
|
|
1776
|
+
nextOffset = Number(banner[1]);
|
|
1777
|
+
if (!Number.isSafeInteger(nextOffset) || nextOffset < 1)
|
|
1778
|
+
return undefined;
|
|
1779
|
+
bodyEnd -= 1;
|
|
1780
|
+
}
|
|
1781
|
+
const expected = end - start + 1;
|
|
1782
|
+
if (bodyEnd - 1 !== expected)
|
|
1783
|
+
return undefined;
|
|
1784
|
+
const raw = [];
|
|
1785
|
+
const truncatedLines = [];
|
|
1786
|
+
for (let index = 0; index < expected; index++) {
|
|
1787
|
+
const match = /^(\d+):[ \t](.*)$/.exec(lines[index + 1] ?? "");
|
|
1788
|
+
if (!match || Number(match[1]) !== start + index)
|
|
1789
|
+
return undefined;
|
|
1790
|
+
const value = match[2] ?? "";
|
|
1791
|
+
if (value.length === OPENCODE2_READ_MAX_LINE_CHARS + OPENCODE2_READ_LINE_TRUNCATION.length
|
|
1792
|
+
&& value.endsWith(OPENCODE2_READ_LINE_TRUNCATION)) {
|
|
1793
|
+
truncatedLines.push(start + index);
|
|
1794
|
+
}
|
|
1795
|
+
raw.push(value);
|
|
1796
|
+
}
|
|
1797
|
+
const content = raw.join("\n");
|
|
1798
|
+
const hostTruncated = nextOffset !== undefined;
|
|
1799
|
+
const requestedLimit = num(resultMetadata?.limit);
|
|
1800
|
+
const effectiveLineLimit = requestedLimit !== undefined && requestedLimit > 0
|
|
1801
|
+
? Math.min(requestedLimit, OPENCODE2_READ_MAX_LINES)
|
|
1802
|
+
: OPENCODE2_READ_MAX_LINES;
|
|
1803
|
+
// OpenCode 2 ends a text page only for the requested/default line limit or
|
|
1804
|
+
// the 50 KiB byte limit. Fewer lines than the effective line limit, together
|
|
1805
|
+
// with a page close enough that one maximum-size rendered line may not fit,
|
|
1806
|
+
// identifies the byte cap without the old ASCII-only 4 KiB guess.
|
|
1807
|
+
const outputCapped = hostTruncated
|
|
1808
|
+
&& expected < effectiveLineLimit
|
|
1809
|
+
&& Buffer.byteLength(content, "utf8") > OPENCODE_READ_MAX_BYTES - OPENCODE2_MAX_RENDERED_LINE_BYTES;
|
|
1810
|
+
if (hostTruncated) {
|
|
1811
|
+
return {
|
|
1812
|
+
path: header[1] ?? "",
|
|
1813
|
+
content,
|
|
1814
|
+
startLine: start,
|
|
1815
|
+
endLine: end,
|
|
1816
|
+
nextOffset,
|
|
1817
|
+
hostTruncated: true,
|
|
1818
|
+
outputCapped,
|
|
1819
|
+
truncatedLines,
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
// A page that reached EOF is complete. Record the span so a non-1 offset is
|
|
1823
|
+
// not later mistaken for a silent cap, without flagging it truncated.
|
|
1824
|
+
return {
|
|
1825
|
+
path: header[1] ?? "",
|
|
1826
|
+
content,
|
|
1827
|
+
...(start === 1
|
|
1828
|
+
? { totalLines: end }
|
|
1829
|
+
: { startLine: start, endLine: end, totalLines: end }),
|
|
1830
|
+
hostTruncated: false,
|
|
1831
|
+
outputCapped: false,
|
|
1832
|
+
truncatedLines,
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* OpenCode 2 directory reads list bare entry names. Join them onto the
|
|
1837
|
+
* directory, then onto the workspace root, so the model can copy a real path
|
|
1838
|
+
* instead of inventing an absolute prefix.
|
|
1839
|
+
*/
|
|
1840
|
+
function parseOpenCode2DirectoryListing(output, workspaceRoot) {
|
|
1841
|
+
const normalized = normalizeToolText(output).replace(/\n+$/, "");
|
|
1842
|
+
if (!normalized.startsWith("Read directory "))
|
|
1843
|
+
return undefined;
|
|
1844
|
+
const lines = normalized.split("\n");
|
|
1845
|
+
const header = /^Read directory (.*), (?:0 entries|entries (\d+)-(\d+))$/.exec((lines[0] ?? "").trim());
|
|
1846
|
+
if (!header) {
|
|
1847
|
+
trace("parseOpenCode2DirectoryListing: OpenCode 2 directory header did not match — leaving output unchanged");
|
|
1848
|
+
return undefined;
|
|
1849
|
+
}
|
|
1850
|
+
const requested = header[1] ?? "";
|
|
1851
|
+
if (!isAbsoluteToolPath(requested) && !workspaceRoot)
|
|
1852
|
+
return undefined;
|
|
1853
|
+
let bodyEnd = lines.length;
|
|
1854
|
+
let banner;
|
|
1855
|
+
if (bodyEnd > 1 && OPENCODE2_READ_TRUNCATION.test(lines[bodyEnd - 1] ?? "")) {
|
|
1856
|
+
banner = lines[bodyEnd - 1];
|
|
1857
|
+
bodyEnd -= 1;
|
|
1858
|
+
}
|
|
1859
|
+
const rawEntries = lines.slice(1, bodyEnd);
|
|
1860
|
+
if (header[2] === undefined) {
|
|
1861
|
+
if (rawEntries.length !== 0)
|
|
1862
|
+
return undefined;
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
const start = Number(header[2]);
|
|
1866
|
+
const end = Number(header[3]);
|
|
1867
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || end - start + 1 !== rawEntries.length) {
|
|
1868
|
+
return undefined;
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const directory = resolveToolPath(requested, workspaceRoot);
|
|
1872
|
+
const directories = [];
|
|
1873
|
+
const files = [];
|
|
1874
|
+
const entries = rawEntries.map((entry) => {
|
|
1875
|
+
const resolved = resolveListedEntry(directory, entry);
|
|
1876
|
+
if (entry.endsWith("/") || entry.endsWith("\\"))
|
|
1877
|
+
directories.push(resolved);
|
|
1878
|
+
else
|
|
1879
|
+
files.push(resolved);
|
|
1880
|
+
return resolved;
|
|
1881
|
+
});
|
|
1882
|
+
const headerLine = header[2] === undefined
|
|
1883
|
+
? `Read directory ${directory}, 0 entries`
|
|
1884
|
+
: `Read directory ${directory}, entries ${header[2]}-${header[3]}`;
|
|
1885
|
+
return {
|
|
1886
|
+
directory,
|
|
1887
|
+
entries,
|
|
1888
|
+
directories,
|
|
1889
|
+
files,
|
|
1890
|
+
text: [headerLine, ...entries, ...(banner ? [banner] : [])].join("\n"),
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* Absolute on this host, plus Windows drive and UNC paths. A posix agent must
|
|
1895
|
+
* not prefix `C:/…` or `\\server\…` just because `path.isAbsolute` rejects them.
|
|
1896
|
+
*/
|
|
1897
|
+
function isAbsoluteToolPath(filePath) {
|
|
1898
|
+
return path.isAbsolute(filePath)
|
|
1899
|
+
|| /^[A-Za-z]:[\\/]/.test(filePath)
|
|
1900
|
+
|| filePath.startsWith("\\\\");
|
|
1901
|
+
}
|
|
1902
|
+
/** Windows drive or UNC path that `path.isAbsolute` rejects on this host. */
|
|
1903
|
+
function isForeignAbsoluteToolPath(filePath) {
|
|
1904
|
+
return isAbsoluteToolPath(filePath) && !path.isAbsolute(filePath);
|
|
1905
|
+
}
|
|
1906
|
+
/** Separator already used by a drive or UNC path. Host paths keep `path.sep`. */
|
|
1907
|
+
function toolPathSeparator(filePath) {
|
|
1908
|
+
if (path.isAbsolute(filePath))
|
|
1909
|
+
return path.sep;
|
|
1910
|
+
if (filePath.startsWith("\\\\") || (filePath.includes("\\") && !filePath.includes("/")))
|
|
1911
|
+
return "\\";
|
|
1912
|
+
return "/";
|
|
1913
|
+
}
|
|
1914
|
+
/**
|
|
1915
|
+
* Join `relative` onto `root`. Node's `path.resolve` treats `C:/…` and
|
|
1916
|
+
* `\\server\…` as relative on posix and prefixes the process cwd. Keep those
|
|
1917
|
+
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1918
|
+
*/
|
|
1919
|
+
function joinToolPath(root, relative) {
|
|
1920
|
+
if (!isForeignAbsoluteToolPath(root))
|
|
1921
|
+
return path.resolve(root, relative);
|
|
1922
|
+
const sep = toolPathSeparator(root);
|
|
1923
|
+
const base = splitForeignAbsolute(root, sep);
|
|
1924
|
+
const parts = [...base.segments];
|
|
1925
|
+
for (const part of relative.split(/[\\/]+/)) {
|
|
1926
|
+
if (!part || part === ".")
|
|
1927
|
+
continue;
|
|
1928
|
+
if (part === "..") {
|
|
1929
|
+
if (parts.length > base.frozen)
|
|
1930
|
+
parts.pop();
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
parts.push(part);
|
|
1934
|
+
}
|
|
1935
|
+
return parts.length === 0 ? base.prefix : `${base.prefix}${parts.join(sep)}`;
|
|
1936
|
+
}
|
|
1937
|
+
function splitForeignAbsolute(filePath, sep) {
|
|
1938
|
+
if (filePath.startsWith("\\\\")) {
|
|
1939
|
+
const segments = filePath.slice(2).split(/[\\/]+/).filter(Boolean);
|
|
1940
|
+
return { prefix: "\\\\", segments, frozen: Math.min(2, segments.length) };
|
|
1941
|
+
}
|
|
1942
|
+
const rest = filePath.slice(2).replace(/^[\\/]+/, "");
|
|
1943
|
+
return {
|
|
1944
|
+
prefix: `${filePath.slice(0, 2)}${sep}`,
|
|
1945
|
+
segments: rest ? rest.split(/[\\/]+/).filter(Boolean) : [],
|
|
1946
|
+
frozen: 0,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
function isRelativePathToken(token) {
|
|
1950
|
+
if (!token || isAbsoluteToolPath(token))
|
|
1951
|
+
return false;
|
|
1952
|
+
// A slash alone does not make arbitrary shell output a path. In particular,
|
|
1953
|
+
// compact JSON, quoted strings, package ids, and shell syntax must remain
|
|
1954
|
+
// byte-for-byte model-visible rather than being prefixed with the workspace.
|
|
1955
|
+
if (/[\0"'`{}\[\]<>|;]/.test(token) || token.startsWith("@"))
|
|
1956
|
+
return false;
|
|
1957
|
+
if (token.startsWith("./")
|
|
1958
|
+
|| token.startsWith("../")
|
|
1959
|
+
|| token.startsWith(".\\")
|
|
1960
|
+
|| token.startsWith("..\\"))
|
|
1961
|
+
return true;
|
|
1962
|
+
return token.includes("/") || (path.sep === "\\" && token.includes("\\"));
|
|
1963
|
+
}
|
|
1964
|
+
function resolveListedEntry(directory, entry) {
|
|
1965
|
+
if (!entry || isAbsoluteToolPath(entry))
|
|
1966
|
+
return entry;
|
|
1967
|
+
if (entry === "~" || entry.startsWith("~/") || entry.startsWith("~\\"))
|
|
1968
|
+
return entry;
|
|
1969
|
+
const directoryEntry = entry.endsWith("/") || entry.endsWith("\\");
|
|
1970
|
+
const resolved = joinToolPath(directory, entry);
|
|
1971
|
+
if (!directoryEntry || resolved.endsWith("/") || resolved.endsWith("\\"))
|
|
1972
|
+
return resolved;
|
|
1973
|
+
return `${resolved}${toolPathSeparator(resolved)}`;
|
|
1974
|
+
}
|
|
1975
|
+
function resolveToolPath(filePath, workspaceRoot) {
|
|
1976
|
+
if (!filePath || isAbsoluteToolPath(filePath))
|
|
1977
|
+
return filePath;
|
|
1978
|
+
if (filePath === "~" || filePath.startsWith("~/") || filePath.startsWith("~\\"))
|
|
1979
|
+
return filePath;
|
|
1980
|
+
if (!workspaceRoot)
|
|
1981
|
+
return filePath;
|
|
1982
|
+
return joinToolPath(workspaceRoot, filePath);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Rewrite OpenCode 2 grep/glob lines that are still project-relative. Absolute
|
|
1986
|
+
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1987
|
+
* drop the trailing colon only in the structured file list, not in this text.
|
|
1988
|
+
*/
|
|
1989
|
+
function groundSearchOutput(output, workspaceRoot) {
|
|
1990
|
+
if (!workspaceRoot)
|
|
1991
|
+
return output;
|
|
1992
|
+
const normalized = normalizeToolText(output);
|
|
1993
|
+
const first = normalized.split("\n", 1)[0] ?? "";
|
|
1994
|
+
const searchShaped = /^Found \d+ matches/.test(first)
|
|
1995
|
+
|| first === "No matches found"
|
|
1996
|
+
|| first === "No files found";
|
|
1997
|
+
if (!searchShaped && !isBarePathList(normalized))
|
|
1998
|
+
return output;
|
|
1999
|
+
return normalized.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
|
|
2000
|
+
}
|
|
2001
|
+
function isBarePathList(output) {
|
|
2002
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2003
|
+
if (lines.length === 0 || lines.length > 2000)
|
|
2004
|
+
return false;
|
|
2005
|
+
return lines.every((line) => {
|
|
2006
|
+
if (line.startsWith("("))
|
|
2007
|
+
return true;
|
|
2008
|
+
if (/\s/.test(line) || line.includes("://"))
|
|
2009
|
+
return false;
|
|
2010
|
+
return true;
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2014
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2015
|
+
return line;
|
|
2016
|
+
if (line.startsWith("Found ") || line === "No matches found" || line === "No files found")
|
|
2017
|
+
return line;
|
|
2018
|
+
const header = /^(.*):$/.exec(line);
|
|
2019
|
+
if (header && !header[1]?.includes("://")) {
|
|
2020
|
+
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
2021
|
+
}
|
|
2022
|
+
if (line.includes("://"))
|
|
2023
|
+
return line;
|
|
2024
|
+
return resolveToolPath(line, workspaceRoot);
|
|
2025
|
+
}
|
|
2026
|
+
/**
|
|
2027
|
+
* Shell stdout is mixed prose. Rewrite only tokens that are clearly relative
|
|
2028
|
+
* paths, including `file:line` and `file:line:col`. Leave sentences, URLs, and
|
|
2029
|
+
* status words alone.
|
|
2030
|
+
*/
|
|
2031
|
+
function groundShellPathText(output, root) {
|
|
2032
|
+
if (!root || !output)
|
|
2033
|
+
return output;
|
|
2034
|
+
return normalizeToolText(output).split("\n").map((line) => rewriteShellPathLine(line, root)).join("\n");
|
|
2035
|
+
}
|
|
2036
|
+
function shellPathRoot(resultMetadata, workspaceRoot) {
|
|
2037
|
+
const workingDirectory = str(resultMetadata?.working_directory)?.trim();
|
|
2038
|
+
if (!workingDirectory)
|
|
2039
|
+
return workspaceRoot;
|
|
2040
|
+
if (isAbsoluteToolPath(workingDirectory))
|
|
2041
|
+
return workingDirectory;
|
|
2042
|
+
return workspaceRoot ? joinToolPath(workspaceRoot, workingDirectory) : undefined;
|
|
2043
|
+
}
|
|
2044
|
+
function rewriteShellPathLine(line, root) {
|
|
2045
|
+
if (!line || line.startsWith(" ") || line.startsWith("\t"))
|
|
2046
|
+
return line;
|
|
2047
|
+
const trimmed = line.trimEnd();
|
|
2048
|
+
if (trimmed.includes("://") || trimmed === "~" || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
2049
|
+
return line;
|
|
2050
|
+
}
|
|
2051
|
+
const located = /^(.+?):(\d+)(?::(\d+))?$/.exec(trimmed);
|
|
2052
|
+
if (located && located[1] && isRelativePathToken(located[1])) {
|
|
2053
|
+
const suffix = located[3] !== undefined ? `:${located[2]}:${located[3]}` : `:${located[2]}`;
|
|
2054
|
+
return `${resolveToolPath(located[1], root)}${suffix}`;
|
|
2055
|
+
}
|
|
2056
|
+
if (/\s/.test(trimmed))
|
|
2057
|
+
return line;
|
|
2058
|
+
const header = /^(.*):$/.exec(trimmed);
|
|
2059
|
+
if (header && header[1] && isRelativePathToken(header[1])) {
|
|
2060
|
+
return `${resolveToolPath(header[1], root)}:`;
|
|
2061
|
+
}
|
|
2062
|
+
if (isRelativePathToken(trimmed))
|
|
2063
|
+
return resolveToolPath(trimmed, root);
|
|
2064
|
+
return line;
|
|
2065
|
+
}
|
|
2066
|
+
function extractGroundedPaths(output, workspaceRoot) {
|
|
2067
|
+
const normalized = normalizeToolText(output);
|
|
2068
|
+
const directory = parseOpenCode2DirectoryListing(normalized, workspaceRoot);
|
|
2069
|
+
if (directory)
|
|
2070
|
+
return directory.entries.slice(0, 2000);
|
|
2071
|
+
const first = normalized.split("\n", 1)[0]?.trim() ?? "";
|
|
2072
|
+
if (/^Found \d+ matches/.test(first) || first === "No matches found" || first === "No files found") {
|
|
2073
|
+
const paths = [];
|
|
2074
|
+
for (const raw of normalized.split("\n").slice(1)) {
|
|
2075
|
+
const line = raw.trim();
|
|
2076
|
+
if (!line || line.startsWith("(") || line.startsWith("Line ") || raw.startsWith(" ") || raw.startsWith("\t")) {
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const header = /^(.*):$/.exec(line);
|
|
2080
|
+
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2081
|
+
if (!candidate || candidate.includes("://"))
|
|
2082
|
+
continue;
|
|
2083
|
+
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2084
|
+
}
|
|
2085
|
+
return paths.slice(0, 2000);
|
|
2086
|
+
}
|
|
2087
|
+
if (isBarePathList(output)) {
|
|
2088
|
+
return output
|
|
2089
|
+
.split("\n")
|
|
2090
|
+
.map((line) => line.trim())
|
|
2091
|
+
.filter((line) => line && !line.startsWith("("))
|
|
2092
|
+
.map((line) => resolveToolPath(line.endsWith(":") && !line.includes("://") ? line.slice(0, -1) : line, workspaceRoot))
|
|
2093
|
+
.slice(0, 2000);
|
|
2094
|
+
}
|
|
2095
|
+
return extractPathLines(output).map((line) => {
|
|
2096
|
+
const trimmed = line.trim();
|
|
2097
|
+
const candidate = trimmed.endsWith(":") && !trimmed.includes("://") ? trimmed.slice(0, -1) : trimmed;
|
|
2098
|
+
return resolveToolPath(candidate, workspaceRoot);
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
1576
2101
|
/**
|
|
1577
2102
|
* Strip opencode's `read` envelope, leaving raw file content.
|
|
1578
2103
|
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
2104
|
+
* OpenCode 1.x (`tool/read.ts`) wraps content in an XML-ish envelope its own
|
|
2105
|
+
* models are trained on, but Cursor's are not:
|
|
1581
2106
|
* <path>{abs}</path>\n<type>file</type>\n<content>\n{N}: {line}\n…\n\n{footer}\n</content>
|
|
1582
|
-
*
|
|
1583
|
-
*
|
|
1584
|
-
*
|
|
1585
|
-
*
|
|
1586
|
-
* `<path>…</path>\n<type>file</type>\n<content>\n1: import fs…`).
|
|
2107
|
+
* OpenCode 2 replaced that with `Read file <path>, lines <start>-<end>` plus
|
|
2108
|
+
* the same `N: ` prefixes and `[Output truncated. Continue reading with offset: N]`.
|
|
2109
|
+
* Forwarding either envelope verbatim made Cursor's model treat the wrapper as
|
|
2110
|
+
* literal file content and write tags or line prefixes back into files.
|
|
1587
2111
|
*
|
|
1588
2112
|
* Returns the raw file body (line numbers + footer + `<system-reminder>` dropped).
|
|
1589
2113
|
*
|
|
@@ -1596,6 +2120,12 @@ export function buildUnsupportedExecDeny(input) {
|
|
|
1596
2120
|
export function unwrapReadOutput(output) {
|
|
1597
2121
|
if (typeof output !== "string" || output.length === 0)
|
|
1598
2122
|
return output;
|
|
2123
|
+
const opencode2 = parseOpenCode2FileRead(output);
|
|
2124
|
+
if (opencode2)
|
|
2125
|
+
return opencode2.content;
|
|
2126
|
+
if (normalizeToolText(output).startsWith("Read file ")) {
|
|
2127
|
+
trace("unwrapReadOutput: OpenCode 2 read header did not match the page parser — leaving output unchanged");
|
|
2128
|
+
}
|
|
1599
2129
|
// Require the full opencode read-envelope skeleton *before* `<content>`
|
|
1600
2130
|
// (read.ts opens with <path>…</path>, <type>file</type>, <content>) so a
|
|
1601
2131
|
// stray "<content>" later in tool chatter can't trigger unwrapping just
|
|
@@ -1646,34 +2176,53 @@ export function unwrapReadOutput(output) {
|
|
|
1646
2176
|
export function buildTypedExecResult(resultField, output, error, toolName, resultMetadata, shellOutcome, workspaceRoot) {
|
|
1647
2177
|
// Prefer the session workspace; never advertise the host process cwd (daemon
|
|
1648
2178
|
// often starts in $HOME) as the path Cursor shows the model for glob/ls.
|
|
1649
|
-
const
|
|
1650
|
-
|
|
2179
|
+
const trimmedRoot = typeof workspaceRoot === "string" ? workspaceRoot.trim() : "";
|
|
2180
|
+
// A posix process must not turn `C:/…` or `\\server\…` into `<cwd>/C:/…`.
|
|
2181
|
+
const resultRoot = trimmedRoot
|
|
2182
|
+
? (isForeignAbsoluteToolPath(trimmedRoot) ? trimmedRoot : path.resolve(trimmedRoot))
|
|
1651
2183
|
: undefined;
|
|
1652
2184
|
switch (resultField) {
|
|
1653
2185
|
case "read_result": {
|
|
1654
|
-
const
|
|
2186
|
+
const parsedFile = parseOpenCode2FileRead(output, resultMetadata);
|
|
2187
|
+
const listing = error ? undefined : parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2188
|
+
const rawPath = str(resultMetadata?.path)
|
|
2189
|
+
?? parsedFile?.path
|
|
2190
|
+
?? listing?.directory
|
|
2191
|
+
?? extractPathTag(output)
|
|
2192
|
+
?? "";
|
|
2193
|
+
const readPath = resolveToolPath(rawPath, resultRoot);
|
|
1655
2194
|
if (error)
|
|
1656
2195
|
return { error: { path: readPath, error } };
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
2196
|
+
// The host may have resolved an alternate spelling after the request
|
|
2197
|
+
// (for example a Unicode-space filename). Use the result's own path for
|
|
2198
|
+
// local stat/newline recovery while preserving the requested path in the
|
|
2199
|
+
// Cursor result, matching the existing OpenCode 1 behavior.
|
|
2200
|
+
const outputPath = parsedFile?.path ?? extractPathTag(output);
|
|
2201
|
+
const statPath = resolveToolPath(outputPath ?? readPath, resultRoot);
|
|
2202
|
+
// Strip OpenCode's read envelope so Cursor's model receives raw file
|
|
2203
|
+
// content and can't echo the wrapper into subsequent writes.
|
|
1659
2204
|
// Cursor's native read_args lands here; `mcp_args` reads land in
|
|
1660
2205
|
// mcp_result. Live captures show gpt-5.4-mini and grok-4.5 both use the
|
|
1661
2206
|
// native channel, so this is the path that matters in practice.
|
|
1662
|
-
const
|
|
1663
|
-
const
|
|
1664
|
-
|
|
2207
|
+
const outputMetadata = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2208
|
+
const content = listing
|
|
2209
|
+
? listing.text
|
|
2210
|
+
: restoreCompleteReadTerminator(unwrapReadOutput(output), statPath, outputMetadata, resultMetadata, resultRoot);
|
|
1665
2211
|
const totalLines = outputMetadata.totalLines ?? readFileLineCount(statPath) ?? countLines(content);
|
|
1666
2212
|
const rangeApplied = readRangeApplied(resultMetadata, totalLines);
|
|
1667
2213
|
// `truncated` alone is not enough: it is set here, and models still assert
|
|
1668
2214
|
// the partial content is the whole file. Cursor's own executor puts the
|
|
1669
2215
|
// limit marker in the output text for the same reason, so append one.
|
|
1670
|
-
const
|
|
2216
|
+
const notices = [
|
|
2217
|
+
readTruncationNotice(output, resultMetadata),
|
|
2218
|
+
readLongLineTruncationNotice(output),
|
|
2219
|
+
].filter((notice) => !!notice);
|
|
1671
2220
|
return {
|
|
1672
2221
|
success: {
|
|
1673
|
-
path: readPath,
|
|
1674
|
-
content:
|
|
2222
|
+
path: listing?.directory || readPath,
|
|
2223
|
+
content: notices.length > 0 ? `${content}\n\n${notices.join("\n\n")}` : content,
|
|
1675
2224
|
total_lines: totalLines,
|
|
1676
|
-
file_size: readFileSize(statPath),
|
|
2225
|
+
file_size: listing ? 0 : readFileSize(statPath),
|
|
1677
2226
|
truncated: readOutputTruncated(resultMetadata, outputMetadata, totalLines),
|
|
1678
2227
|
range_applied: rangeApplied,
|
|
1679
2228
|
},
|
|
@@ -1684,7 +2233,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1684
2233
|
return { error: { error } };
|
|
1685
2234
|
// Prefer files_with_matches: OpenCode glob/grep often returns path lists.
|
|
1686
2235
|
// Content-mode GrepSuccess also works but needs GrepFileMatch nesting.
|
|
1687
|
-
const files =
|
|
2236
|
+
const files = extractGroundedPaths(output, resultRoot);
|
|
1688
2237
|
const cwd = resultRoot ?? "";
|
|
1689
2238
|
return {
|
|
1690
2239
|
success: {
|
|
@@ -1728,19 +2277,20 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1728
2277
|
// Pi results carry truncation structurally (PiReadExecSuccess field 2),
|
|
1729
2278
|
// which is how Cursor's own executors report a capped payload.
|
|
1730
2279
|
const content = unwrapReadOutput(output);
|
|
1731
|
-
const truncation = readTruncationMessage(output, content);
|
|
2280
|
+
const truncation = readTruncationMessage(output, content, resultMetadata);
|
|
1732
2281
|
return { success: { output: content, ...(truncation ? { truncation } : {}) } };
|
|
1733
2282
|
}
|
|
1734
2283
|
case "shell_result": {
|
|
1735
2284
|
const command = str(resultMetadata?.command) ?? "";
|
|
1736
2285
|
const workingDirectory = str(resultMetadata?.working_directory) ?? "";
|
|
2286
|
+
const stdout = groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot));
|
|
1737
2287
|
if (error) {
|
|
1738
2288
|
return {
|
|
1739
2289
|
failure: {
|
|
1740
2290
|
command,
|
|
1741
2291
|
working_directory: workingDirectory,
|
|
1742
2292
|
exit_code: 1,
|
|
1743
|
-
stdout:
|
|
2293
|
+
stdout: stdout || "",
|
|
1744
2294
|
stderr: error,
|
|
1745
2295
|
aborted: false,
|
|
1746
2296
|
},
|
|
@@ -1761,7 +2311,7 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1761
2311
|
command: shellOutcome.command || command,
|
|
1762
2312
|
working_directory: shellOutcome.workingDirectory || workingDirectory,
|
|
1763
2313
|
exit_code: 0,
|
|
1764
|
-
stdout:
|
|
2314
|
+
stdout: stdout,
|
|
1765
2315
|
shell_id: shellOutcome.shellId,
|
|
1766
2316
|
pid: shellOutcome.pid,
|
|
1767
2317
|
ms_to_wait: shellOutcome.msToWait,
|
|
@@ -1779,18 +2329,29 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1779
2329
|
command,
|
|
1780
2330
|
working_directory: workingDirectory,
|
|
1781
2331
|
exit_code: exitCode,
|
|
1782
|
-
stdout:
|
|
2332
|
+
stdout: stdout,
|
|
1783
2333
|
},
|
|
1784
2334
|
};
|
|
1785
2335
|
}
|
|
1786
2336
|
case "pi_bash_result":
|
|
2337
|
+
if (error)
|
|
2338
|
+
return { error: { error } };
|
|
2339
|
+
return { success: { output: groundShellPathText(output, shellPathRoot(resultMetadata, resultRoot)) } };
|
|
1787
2340
|
case "pi_edit_result":
|
|
2341
|
+
if (error)
|
|
2342
|
+
return { error: { error } };
|
|
2343
|
+
return { success: { output } };
|
|
1788
2344
|
case "pi_grep_result":
|
|
1789
2345
|
case "pi_find_result":
|
|
1790
|
-
case "pi_ls_result":
|
|
1791
2346
|
if (error)
|
|
1792
2347
|
return { error: { error } };
|
|
1793
|
-
return { success: { output } };
|
|
2348
|
+
return { success: { output: groundSearchOutput(output, resultRoot) } };
|
|
2349
|
+
case "pi_ls_result": {
|
|
2350
|
+
if (error)
|
|
2351
|
+
return { error: { error } };
|
|
2352
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2353
|
+
return { success: { output: listing?.text ?? groundSearchOutput(output, resultRoot) } };
|
|
2354
|
+
}
|
|
1794
2355
|
case "delete_result":
|
|
1795
2356
|
if (error)
|
|
1796
2357
|
return { error: { path: "", error } };
|
|
@@ -1836,17 +2397,25 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1836
2397
|
case "ls_result": {
|
|
1837
2398
|
if (error)
|
|
1838
2399
|
return { error: { path: "", error } };
|
|
1839
|
-
const
|
|
1840
|
-
const
|
|
2400
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2401
|
+
const rootPath = listing?.directory || resultRoot || "";
|
|
2402
|
+
const entries = listing ? listing.entries : extractGroundedPaths(output, resultRoot);
|
|
2403
|
+
const directories = listing?.directories ?? [];
|
|
2404
|
+
const files = listing?.files ?? entries;
|
|
1841
2405
|
return {
|
|
1842
2406
|
success: {
|
|
1843
2407
|
directory_tree_root: {
|
|
1844
2408
|
abs_path: rootPath,
|
|
1845
|
-
children_dirs:
|
|
1846
|
-
|
|
1847
|
-
|
|
2409
|
+
children_dirs: directories.map((entry) => ({
|
|
2410
|
+
abs_path: stripTrailingPathSeparator(entry),
|
|
2411
|
+
children_dirs: [],
|
|
2412
|
+
children_files: [],
|
|
2413
|
+
num_files: 0,
|
|
1848
2414
|
})),
|
|
1849
|
-
|
|
2415
|
+
children_files: files.map((entry) => ({
|
|
2416
|
+
name: listedEntryName(entry),
|
|
2417
|
+
})),
|
|
2418
|
+
num_files: files.length,
|
|
1850
2419
|
},
|
|
1851
2420
|
},
|
|
1852
2421
|
};
|
|
@@ -1858,17 +2427,34 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
1858
2427
|
// a read call returns through mcp_result. Scope the unwrap to toolName
|
|
1859
2428
|
// "read" so a non-read MCP tool whose output merely contains a
|
|
1860
2429
|
// "<content>"-like block is never rewritten.
|
|
2430
|
+
if (toolName === "grep" || toolName === "glob") {
|
|
2431
|
+
return {
|
|
2432
|
+
success: {
|
|
2433
|
+
content: [{ text: { text: groundSearchOutput(output, resultRoot) } }],
|
|
2434
|
+
is_error: false,
|
|
2435
|
+
},
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
1861
2438
|
if (toolName !== "read") {
|
|
1862
2439
|
return { success: { content: [{ text: { text: output } }], is_error: false } };
|
|
1863
2440
|
}
|
|
2441
|
+
const listing = parseOpenCode2DirectoryListing(output, resultRoot);
|
|
2442
|
+
if (listing) {
|
|
2443
|
+
return {
|
|
2444
|
+
success: { content: [{ text: { text: listing.text } }], is_error: false },
|
|
2445
|
+
};
|
|
2446
|
+
}
|
|
1864
2447
|
// Carry the truncation notice as its own content item: the file content
|
|
1865
2448
|
// item stays byte-exact, so it can still never be echoed into a write.
|
|
1866
|
-
const
|
|
2449
|
+
const notices = [
|
|
2450
|
+
readTruncationNotice(output, resultMetadata),
|
|
2451
|
+
readLongLineTruncationNotice(output),
|
|
2452
|
+
].filter((notice) => !!notice);
|
|
1867
2453
|
return {
|
|
1868
2454
|
success: {
|
|
1869
2455
|
content: [
|
|
1870
2456
|
{ text: { text: unwrapReadOutput(output) } },
|
|
1871
|
-
...(notice
|
|
2457
|
+
...notices.map((notice) => ({ text: { text: notice } })),
|
|
1872
2458
|
],
|
|
1873
2459
|
is_error: false,
|
|
1874
2460
|
},
|
|
@@ -1945,7 +2531,19 @@ function readEnvelopeFooter(output) {
|
|
|
1945
2531
|
return output.slice(start + 2, close);
|
|
1946
2532
|
}
|
|
1947
2533
|
/** Recover full-file metadata before unwrapReadOutput removes OpenCode's footer. */
|
|
1948
|
-
function parseOpenCodeReadMetadata(output) {
|
|
2534
|
+
function parseOpenCodeReadMetadata(output, resultMetadata) {
|
|
2535
|
+
const opencode2 = parseOpenCode2FileRead(output, resultMetadata);
|
|
2536
|
+
if (opencode2) {
|
|
2537
|
+
return {
|
|
2538
|
+
...(opencode2.startLine !== undefined ? { startLine: opencode2.startLine } : {}),
|
|
2539
|
+
...(opencode2.endLine !== undefined ? { endLine: opencode2.endLine } : {}),
|
|
2540
|
+
...(opencode2.totalLines !== undefined ? { totalLines: opencode2.totalLines } : {}),
|
|
2541
|
+
...(opencode2.nextOffset !== undefined ? { nextOffset: opencode2.nextOffset } : {}),
|
|
2542
|
+
outputCapped: opencode2.outputCapped,
|
|
2543
|
+
hostTruncated: opencode2.hostTruncated,
|
|
2544
|
+
...(opencode2.truncatedLines.length > 0 ? { truncatedLines: opencode2.truncatedLines } : {}),
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
1949
2547
|
const footer = readEnvelopeFooter(output);
|
|
1950
2548
|
const showing = /Showing lines (\d+)-(\d+)(?: of (\d+))?\./.exec(footer);
|
|
1951
2549
|
if (showing) {
|
|
@@ -1977,9 +2575,9 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
1977
2575
|
|| num(resultMetadata?.limit) !== undefined
|
|
1978
2576
|
|| content.endsWith("\n"))
|
|
1979
2577
|
return content;
|
|
1980
|
-
const absolute =
|
|
2578
|
+
const absolute = isAbsoluteToolPath(readPath)
|
|
1981
2579
|
? readPath
|
|
1982
|
-
:
|
|
2580
|
+
: joinToolPath(workspaceRoot ?? process.cwd(), readPath);
|
|
1983
2581
|
let fd;
|
|
1984
2582
|
try {
|
|
1985
2583
|
fd = fs.openSync(absolute, "r");
|
|
@@ -2026,8 +2624,8 @@ function restoreCompleteReadTerminator(content, readPath, metadata, resultMetada
|
|
|
2026
2624
|
* that, keeping the signal out of the file content itself so it still cannot be
|
|
2027
2625
|
* echoed into a write.
|
|
2028
2626
|
*/
|
|
2029
|
-
function readTruncationSummary(output) {
|
|
2030
|
-
const meta = parseOpenCodeReadMetadata(output);
|
|
2627
|
+
function readTruncationSummary(output, resultMetadata) {
|
|
2628
|
+
const meta = parseOpenCodeReadMetadata(output, resultMetadata);
|
|
2031
2629
|
if (meta.startLine === undefined || meta.endLine === undefined)
|
|
2032
2630
|
return undefined;
|
|
2033
2631
|
// A complete read reports "(End of file …)" and never reaches this shape.
|
|
@@ -2036,7 +2634,7 @@ function readTruncationSummary(output) {
|
|
|
2036
2634
|
return {
|
|
2037
2635
|
startLine: meta.startLine,
|
|
2038
2636
|
endLine: meta.endLine,
|
|
2039
|
-
nextOffset: meta.endLine + 1,
|
|
2637
|
+
nextOffset: meta.nextOffset ?? meta.endLine + 1,
|
|
2040
2638
|
capped: meta.outputCapped === true,
|
|
2041
2639
|
...(meta.totalLines !== undefined ? { totalLines: meta.totalLines } : {}),
|
|
2042
2640
|
};
|
|
@@ -2049,7 +2647,7 @@ function readTruncationSummary(output) {
|
|
|
2049
2647
|
* would cry wolf on every deliberate paged read.
|
|
2050
2648
|
*/
|
|
2051
2649
|
function readTruncationNotice(output, resultMetadata) {
|
|
2052
|
-
const summary = readTruncationSummary(output);
|
|
2650
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2053
2651
|
if (!summary)
|
|
2054
2652
|
return undefined;
|
|
2055
2653
|
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
@@ -2064,18 +2662,35 @@ function readTruncationNotice(output, resultMetadata) {
|
|
|
2064
2662
|
`acting on the whole file; writing the content above back would delete everything ` +
|
|
2065
2663
|
`after line ${summary.endLine}.]`);
|
|
2066
2664
|
}
|
|
2665
|
+
function readLongLineTruncationNotice(output) {
|
|
2666
|
+
const lines = parseOpenCodeReadMetadata(output).truncatedLines;
|
|
2667
|
+
if (!lines || lines.length === 0)
|
|
2668
|
+
return undefined;
|
|
2669
|
+
const displayed = lines.slice(0, 8).join(", ");
|
|
2670
|
+
const remainder = lines.length > 8 ? ` and ${lines.length - 8} more` : "";
|
|
2671
|
+
return (`[Partial read: OpenCode shortened ${lines.length === 1 ? "line" : "lines"} ` +
|
|
2672
|
+
`${displayed}${remainder} to ${OPENCODE2_READ_MAX_LINE_CHARS} characters. ` +
|
|
2673
|
+
"It is NOT the complete file. Use a byte-preserving read method to inspect the full " +
|
|
2674
|
+
"line content before acting on the whole file; writing the content above back would lose data.]");
|
|
2675
|
+
}
|
|
2067
2676
|
/** agent.v1.PiTruncation for a capped OpenCode read. */
|
|
2068
|
-
function readTruncationMessage(output, content) {
|
|
2069
|
-
const summary = readTruncationSummary(output);
|
|
2070
|
-
|
|
2677
|
+
function readTruncationMessage(output, content, resultMetadata) {
|
|
2678
|
+
const summary = readTruncationSummary(output, resultMetadata);
|
|
2679
|
+
const longLines = parseOpenCodeReadMetadata(output, resultMetadata).truncatedLines;
|
|
2680
|
+
if (!summary && (!longLines || longLines.length === 0))
|
|
2681
|
+
return undefined;
|
|
2682
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2683
|
+
if (summary && rangeRequested && !summary.capped && (!longLines || longLines.length === 0))
|
|
2071
2684
|
return undefined;
|
|
2072
2685
|
return {
|
|
2073
2686
|
truncated: true,
|
|
2074
|
-
truncated_by: summary
|
|
2075
|
-
...(summary
|
|
2076
|
-
output_lines:
|
|
2687
|
+
truncated_by: summary?.capped ? "bytes" : longLines?.length ? "characters" : "lines",
|
|
2688
|
+
...(summary?.totalLines !== undefined ? { total_lines: summary.totalLines } : {}),
|
|
2689
|
+
output_lines: summary
|
|
2690
|
+
? Math.max(0, summary.endLine - summary.startLine + 1)
|
|
2691
|
+
: countLines(content),
|
|
2077
2692
|
output_bytes: Buffer.byteLength(content, "utf8"),
|
|
2078
|
-
...(summary
|
|
2693
|
+
...(summary?.capped ? { max_bytes: OPENCODE_READ_MAX_BYTES } : {}),
|
|
2079
2694
|
};
|
|
2080
2695
|
}
|
|
2081
2696
|
/** `tool/read.ts` MAX_BYTES — mirrored only to report the cap, never to apply it. */
|
|
@@ -2091,8 +2706,15 @@ function readRangeApplied(resultMetadata, totalLines) {
|
|
|
2091
2706
|
return startLine < 0 || startLine <= totalLines;
|
|
2092
2707
|
}
|
|
2093
2708
|
function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
2709
|
+
if (outputMetadata.truncatedLines && outputMetadata.truncatedLines.length > 0)
|
|
2710
|
+
return true;
|
|
2094
2711
|
if (outputMetadata.outputCapped)
|
|
2095
2712
|
return true;
|
|
2713
|
+
if (outputMetadata.hostTruncated) {
|
|
2714
|
+
const rangeRequested = num(resultMetadata?.offset) !== undefined || num(resultMetadata?.limit) !== undefined;
|
|
2715
|
+
if (!rangeRequested)
|
|
2716
|
+
return true;
|
|
2717
|
+
}
|
|
2096
2718
|
const returnedEnd = outputMetadata.endLine;
|
|
2097
2719
|
if (returnedEnd === undefined || totalLines === 0)
|
|
2098
2720
|
return false;
|
|
@@ -2106,6 +2728,14 @@ function readOutputTruncated(resultMetadata, outputMetadata, totalLines) {
|
|
|
2106
2728
|
: Math.min(totalLines, Math.max(1, startLine) + limit - 1);
|
|
2107
2729
|
return returnedEnd < expectedEnd;
|
|
2108
2730
|
}
|
|
2731
|
+
function listedEntryName(entry) {
|
|
2732
|
+
const trimmed = stripTrailingPathSeparator(entry);
|
|
2733
|
+
const slash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
2734
|
+
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
|
2735
|
+
}
|
|
2736
|
+
function stripTrailingPathSeparator(entry) {
|
|
2737
|
+
return entry.endsWith("/") || entry.endsWith("\\") ? entry.slice(0, -1) : entry;
|
|
2738
|
+
}
|
|
2109
2739
|
function readFileSize(filePath) {
|
|
2110
2740
|
if (!filePath)
|
|
2111
2741
|
return 0;
|