cursor-opencode-provider 0.7.2 → 0.7.4
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 +8 -5
- package/dist/context/agents.js +1 -1
- package/dist/context/build.d.ts +2 -0
- package/dist/context/build.js +22 -10
- package/dist/context/epoch.d.ts +59 -0
- package/dist/context/epoch.js +224 -0
- package/dist/context/frozen.d.ts +4 -2
- package/dist/context/frozen.js +31 -11
- package/dist/context/index.d.ts +2 -0
- package/dist/context/index.js +2 -0
- package/dist/context/overlay.d.ts +44 -0
- package/dist/context/overlay.js +96 -0
- package/dist/debug.d.ts +9 -1
- package/dist/debug.js +46 -5
- package/dist/host-event-bridge.d.ts +22 -0
- package/dist/host-event-bridge.js +22 -0
- package/dist/image-input.js +36 -11
- package/dist/index.d.ts +3 -0
- package/dist/language-model.d.ts +51 -3
- package/dist/language-model.js +569 -140
- package/dist/model-config.js +5 -1
- package/dist/opencode2/mcp-direct.d.ts +26 -0
- package/dist/opencode2/mcp-direct.js +60 -0
- package/dist/opencode2/types.d.ts +24 -0
- package/dist/plugin-opencode2.js +24 -4
- package/dist/plugin.js +26 -15
- package/dist/pricing-data.d.ts +40 -0
- package/dist/pricing-data.js +40 -0
- package/dist/protocol/ask-question.d.ts +8 -9
- package/dist/protocol/ask-question.js +8 -9
- package/dist/protocol/conversation-bind.js +18 -8
- package/dist/protocol/create-plan.d.ts +6 -7
- package/dist/protocol/create-plan.js +4 -5
- package/dist/protocol/messages.js +16 -1
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/switch-mode.d.ts +3 -4
- package/dist/protocol/switch-mode.js +21 -12
- package/dist/protocol/tool-call-bridge.d.ts +4 -1
- package/dist/protocol/tool-call-bridge.js +27 -5
- package/dist/protocol/tools.d.ts +4 -2
- package/dist/protocol/tools.js +247 -51
- package/dist/session-directory.d.ts +23 -4
- package/dist/session-directory.js +36 -4
- package/dist/session.d.ts +23 -3
- package/dist/session.js +68 -8
- package/dist/shared.d.ts +2 -0
- package/dist/shared.js +2 -0
- package/dist/transport/connect.js +78 -10
- package/dist/transport/https-proxy.d.ts +56 -0
- package/dist/transport/https-proxy.js +348 -0
- package/dist/usage.d.ts +11 -0
- package/dist/usage.js +33 -7
- package/package.json +1 -1
package/dist/protocol/struct.js
CHANGED
|
@@ -249,7 +249,7 @@ export function encodeJsonAsValue(v) {
|
|
|
249
249
|
}
|
|
250
250
|
else if (typeof v === "object") {
|
|
251
251
|
const st = [];
|
|
252
|
-
for (const [key, val] of Object.entries(v).sort(([left], [right]) => left
|
|
252
|
+
for (const [key, val] of Object.entries(v).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
|
|
253
253
|
if (val === undefined)
|
|
254
254
|
continue;
|
|
255
255
|
const entry = [];
|
|
@@ -30,10 +30,8 @@
|
|
|
30
30
|
* - leaving plan mode is the gate the user must actually see, so it falls back
|
|
31
31
|
* to the host `question` tool and only rejects when that is absent too.
|
|
32
32
|
*
|
|
33
|
-
* Resolution is keyed solely on the advertised catalog under canonical
|
|
34
|
-
* names — never on
|
|
35
|
-
* alternate host vocabulary under canonical names before the provider sees the
|
|
36
|
-
* catalog, so the bridge itself remains OpenCode-shaped.
|
|
33
|
+
* Resolution is keyed solely on the advertised catalog under canonical
|
|
34
|
+
* OpenCode tool names — never on any external identity or model id.
|
|
37
35
|
*
|
|
38
36
|
* CLI reject reason for user declines: "Mode switch rejected by user"
|
|
39
37
|
* (chunk-7076/dist/ui.js onSwitchModeReject).
|
|
@@ -169,6 +167,7 @@ export declare function resetActiveCursorModesForTests(): void;
|
|
|
169
167
|
export declare function cursorModeSystemReminder(targetModeId: string, options?: {
|
|
170
168
|
firstTurn?: boolean;
|
|
171
169
|
planExitAdvertised?: boolean;
|
|
170
|
+
planStageAdvertised?: boolean;
|
|
172
171
|
}): string | undefined;
|
|
173
172
|
/**
|
|
174
173
|
* Consume the active-mode reminder for this OpenCode session.
|
|
@@ -30,10 +30,8 @@
|
|
|
30
30
|
* - leaving plan mode is the gate the user must actually see, so it falls back
|
|
31
31
|
* to the host `question` tool and only rejects when that is absent too.
|
|
32
32
|
*
|
|
33
|
-
* Resolution is keyed solely on the advertised catalog under canonical
|
|
34
|
-
* names — never on
|
|
35
|
-
* alternate host vocabulary under canonical names before the provider sees the
|
|
36
|
-
* catalog, so the bridge itself remains OpenCode-shaped.
|
|
33
|
+
* Resolution is keyed solely on the advertised catalog under canonical
|
|
34
|
+
* OpenCode tool names — never on any external identity or model id.
|
|
37
35
|
*
|
|
38
36
|
* CLI reject reason for user declines: "Mode switch rejected by user"
|
|
39
37
|
* (chunk-7076/dist/ui.js onSwitchModeReject).
|
|
@@ -295,12 +293,15 @@ export function cursorModeSystemReminder(targetModeId, options = {}) {
|
|
|
295
293
|
if (!id)
|
|
296
294
|
return undefined;
|
|
297
295
|
const first = options.firstTurn !== false;
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
296
|
+
// A host plan-stage tool writes the artifact and its result names the
|
|
297
|
+
// follow-up that requests approval. plan_exit only leaves plan mode.
|
|
298
|
+
// Without either tool, recording the plan is the gate and the provider asks
|
|
299
|
+
// whether to start implementing. Naming an unavailable tool would strand the model.
|
|
300
|
+
const leavePlan = options.planStageAdvertised
|
|
301
|
+
? "record the finished plan with Cursor CreatePlan. The host stage tool waits for the host plan review and does not return until the user accepts or declines. Do not call `plan_exit` to submit or skip that review, and do not implement until the tool returns success"
|
|
302
|
+
: options.planExitAdvertised === false
|
|
303
|
+
? "record the finished plan (Cursor CreatePlan). Writing it needs no approval, and the user is then asked whether to start implementing; if they decline, refine the plan and record it again"
|
|
304
|
+
: "record the finished plan, then call OpenCode `plan_exit` so the user can approve leaving plan mode";
|
|
304
305
|
if (id === "plan" || id === "spec") {
|
|
305
306
|
return wrapReminder(first
|
|
306
307
|
? `Plan mode is active. The user does not want execution yet -- you MUST NOT make edits, run non-readonly tools (including changing configs or making commits), or otherwise modify system state. This supersedes any conflicting instruction.
|
|
@@ -389,9 +390,12 @@ Your mandate is to convert user intent into a correct, high-quality implementati
|
|
|
389
390
|
return wrapReminder(first
|
|
390
391
|
? `Agent mode is active (OpenCode build / agents). You have left plan mode and may implement: edit files, run tools, and execute the agreed approach.
|
|
391
392
|
|
|
393
|
+
The plan-mode restriction is over. An earlier reminder that forbade edits no longer applies.
|
|
394
|
+
|
|
395
|
+
- Change file contents with the advertised \`write\` and \`edit\` tools.
|
|
392
396
|
- Prefer making progress with the advertised host tools.
|
|
393
397
|
- If the task is large or ambiguous again, switch back to plan mode (OpenCode \`plan_enter\` / SwitchMode target plan) before a large rewrite.`
|
|
394
|
-
: `Agent mode is still active. Continue implementing with the advertised host tools
|
|
398
|
+
: `Agent mode is still active. Continue implementing with the advertised host tools, and change file contents with \`write\` and \`edit\`. Switch back to plan mode only when a new large/ambiguous design decision appears.`);
|
|
395
399
|
}
|
|
396
400
|
/**
|
|
397
401
|
* Consume the active-mode reminder for this OpenCode session.
|
|
@@ -417,7 +421,12 @@ export function takeActiveCursorModeReminder(sessionKey, options = {}) {
|
|
|
417
421
|
}
|
|
418
422
|
const reminder = cursorModeSystemReminder(state.modeId, {
|
|
419
423
|
firstTurn: state.firstTurn,
|
|
420
|
-
...(advertised
|
|
424
|
+
...(advertised
|
|
425
|
+
? {
|
|
426
|
+
planExitAdvertised: advertised.has("plan_exit"),
|
|
427
|
+
planStageAdvertised: advertised.has("cursor_plan_stage"),
|
|
428
|
+
}
|
|
429
|
+
: {}),
|
|
421
430
|
});
|
|
422
431
|
if (state.firstTurn)
|
|
423
432
|
state.firstTurn = false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type HostToolDialect } from "./tools.js";
|
|
1
2
|
/**
|
|
2
3
|
* Cursor's interaction_update.tool_call_* carries a typed ToolCall oneof
|
|
3
4
|
* (shell_tool_call, update_todos_tool_call, mcp_tool_call, …). Some of those
|
|
@@ -19,6 +20,8 @@ export type BridgedOpenCodeToolCall = {
|
|
|
19
20
|
callId: string;
|
|
20
21
|
variant: string;
|
|
21
22
|
};
|
|
23
|
+
/** Display variants Cursor executes itself (mcp_state + optional write spill). */
|
|
24
|
+
export declare function isNativeDisplayToolCall(variant: string): boolean;
|
|
22
25
|
/**
|
|
23
26
|
* Normalize a full todo snapshot for `mirroredTodos` storage. Returns
|
|
24
27
|
* undefined when the input is not a todo array (nothing to learn). Drops the
|
|
@@ -55,7 +58,7 @@ export declare function parseDisplayToolCall(callId: string, toolCall: Record<st
|
|
|
55
58
|
* Pick an advertised OpenCode tool for a display call, remapping args as needed.
|
|
56
59
|
* Returns undefined when nothing compatible is advertised this turn.
|
|
57
60
|
*/
|
|
58
|
-
export declare function resolveBridgedOpenCodeToolCall(display: DisplayToolCall, advertised: Iterable<string
|
|
61
|
+
export declare function resolveBridgedOpenCodeToolCall(display: DisplayToolCall, advertised: Iterable<string>, dialect?: HostToolDialect): BridgedOpenCodeToolCall | undefined;
|
|
59
62
|
/** Advertised OpenCode tool ids from Cursor McpToolDefinition descriptors. */
|
|
60
63
|
export declare function advertisedToolNamesFromDescriptors(descriptors: Array<Record<string, unknown>>): string[];
|
|
61
64
|
/** Walk a protobuf message and return top-level field numbers present. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
|
|
1
|
+
import { OPENCODE_1_TOOL_DIALECT, mapCursorArgsToOpencode, mapCursorSubagentTypeToOpenCode, mcpRealToolName, } from "./tools.js";
|
|
2
2
|
import { decodeStructEntriesToJson } from "./struct.js";
|
|
3
3
|
import { mapSwitchModeTarget } from "./switch-mode.js";
|
|
4
4
|
const TODO_STATUS = {
|
|
@@ -50,11 +50,22 @@ const TOOL_CALL_VARIANTS = Object.keys(VARIANT_TO_OPENCODE);
|
|
|
50
50
|
// Cursor todo merges). Data-returning, interactive, and side-effecting calls
|
|
51
51
|
// must use an exec/interaction request channel where Cursor can receive their
|
|
52
52
|
// actual result.
|
|
53
|
+
//
|
|
54
|
+
// `get_mcp_tools_tool_call` is Cursor-native discovery (prompt name
|
|
55
|
+
// GetDynamicTools when dynamic namespaces are on). The agent executes it
|
|
56
|
+
// server-side after mcp_state; oversized catalogs spill through write_args.
|
|
53
57
|
const DISPLAY_STATE_MIRROR_VARIANTS = new Set([
|
|
54
58
|
"update_todos_tool_call",
|
|
55
59
|
"read_todos_tool_call",
|
|
56
60
|
"create_plan_tool_call",
|
|
57
61
|
]);
|
|
62
|
+
const NATIVE_DISPLAY_VARIANTS = new Set([
|
|
63
|
+
"get_mcp_tools_tool_call",
|
|
64
|
+
]);
|
|
65
|
+
/** Display variants Cursor executes itself (mcp_state + optional write spill). */
|
|
66
|
+
export function isNativeDisplayToolCall(variant) {
|
|
67
|
+
return NATIVE_DISPLAY_VARIANTS.has(variant);
|
|
68
|
+
}
|
|
58
69
|
function asRecord(v) {
|
|
59
70
|
return v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
60
71
|
}
|
|
@@ -291,7 +302,13 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
|
|
|
291
302
|
if (variant === "update_todos_tool_call" || variant === "create_plan_tool_call") {
|
|
292
303
|
const result = asRecord(payload.result);
|
|
293
304
|
const success = asRecord(result?.success);
|
|
294
|
-
|
|
305
|
+
// Cursor's completed frame may materialize proto-default `todos: []` even
|
|
306
|
+
// when the real final snapshot is present on args.todos. An empty default
|
|
307
|
+
// must not erase that named completion snapshot. A non-empty completed
|
|
308
|
+
// payload remains authoritative (especially for merge calls).
|
|
309
|
+
const completedTodos = Array.isArray(success?.todos) && success.todos.length > 0
|
|
310
|
+
? success.todos
|
|
311
|
+
: undefined;
|
|
295
312
|
const isMerge = variant === "update_todos_tool_call" && args.merge === true;
|
|
296
313
|
// OpenCode todowrite replaces the whole list. Prefer the completed payload;
|
|
297
314
|
// otherwise a non-merge args list; otherwise merge against the Run snapshot.
|
|
@@ -311,7 +328,10 @@ export function parseDisplayToolCall(callId, toolCall, priorMirroredTodos) {
|
|
|
311
328
|
todos.unshift({
|
|
312
329
|
id: "plan",
|
|
313
330
|
content: [name && `Plan: ${name}`, overview, plan].filter(Boolean).join("\n").slice(0, 2000),
|
|
314
|
-
|
|
331
|
+
// The plan file is already written by the time this display mirror
|
|
332
|
+
// runs. Leaving it `pending` parks a forever-open row on hosts that
|
|
333
|
+
// keep todowrite as the user-visible checklist.
|
|
334
|
+
status: "completed",
|
|
315
335
|
priority: "high",
|
|
316
336
|
});
|
|
317
337
|
}
|
|
@@ -454,7 +474,9 @@ function shellQuote(s) {
|
|
|
454
474
|
* Pick an advertised OpenCode tool for a display call, remapping args as needed.
|
|
455
475
|
* Returns undefined when nothing compatible is advertised this turn.
|
|
456
476
|
*/
|
|
457
|
-
export function resolveBridgedOpenCodeToolCall(display, advertised) {
|
|
477
|
+
export function resolveBridgedOpenCodeToolCall(display, advertised, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
478
|
+
if (isNativeDisplayToolCall(display.variant))
|
|
479
|
+
return undefined;
|
|
458
480
|
if (!DISPLAY_STATE_MIRROR_VARIANTS.has(display.variant))
|
|
459
481
|
return undefined;
|
|
460
482
|
if (display.bridgeable === false)
|
|
@@ -466,7 +488,7 @@ export function resolveBridgedOpenCodeToolCall(display, advertised) {
|
|
|
466
488
|
for (const toolName of candidates) {
|
|
467
489
|
if (!names.has(toolName))
|
|
468
490
|
continue;
|
|
469
|
-
const mapped = mapCursorArgsToOpencode(toolName, display.args);
|
|
491
|
+
const mapped = mapCursorArgsToOpencode(toolName, display.args, undefined, dialect);
|
|
470
492
|
if (toolName === "todowrite") {
|
|
471
493
|
mapped.args = {
|
|
472
494
|
todos: mapTodos(display.args.todos ?? mapped.args.todos),
|
package/dist/protocol/tools.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type HostToolDialect = {
|
|
|
17
17
|
shellTool: HostShellTool;
|
|
18
18
|
};
|
|
19
19
|
export declare const OPENCODE_1_TOOL_DIALECT: HostToolDialect;
|
|
20
|
+
export declare const OPENCODE_2_TOOL_DIALECT: HostToolDialect;
|
|
20
21
|
/** Prefer `filePath`, then `path` / `file_path` — Cursor and both OpenCode majors. */
|
|
21
22
|
export declare function opencodePathArg(args: Record<string, unknown> | undefined): string | undefined;
|
|
22
23
|
/**
|
|
@@ -26,7 +27,7 @@ export declare function opencodePathArg(args: Record<string, unknown> | undefine
|
|
|
26
27
|
export declare function hostToolDialectFromTools(tools: readonly {
|
|
27
28
|
name?: string;
|
|
28
29
|
inputSchema?: unknown;
|
|
29
|
-
}[]): HostToolDialect;
|
|
30
|
+
}[], defaultDialect?: HostToolDialect): HostToolDialect;
|
|
30
31
|
export declare const CUSTOM_WEBSEARCH_TOOL = "custom_websearch";
|
|
31
32
|
export declare const CUSTOM_WEBFETCH_TOOL = "custom_webfetch";
|
|
32
33
|
export declare const CUSTOM_LIST_MCP_RESOURCES_TOOL = "custom_list_mcp_resources";
|
|
@@ -62,7 +63,8 @@ export declare function resolveToolServerIdentity(opencodeName: string, defaultS
|
|
|
62
63
|
* default server (`opencode`). Tools whose prefixes match configured MCP
|
|
63
64
|
* servers keep those server ids (`github`, …). Composite `name` is
|
|
64
65
|
* `<server>-<bareTool>`; local execution still uses the full OpenCode id
|
|
65
|
-
* reconstructed in `mcpRealToolName`.
|
|
66
|
+
* reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
|
|
67
|
+
* catalog owns canonicalization; sorting here would insert on grow.
|
|
66
68
|
*/
|
|
67
69
|
export declare function toolsToDescriptors(tools: OpencodeToolDef[], providerIdentifier?: string, knownMcpServers?: Iterable<string>): Array<Record<string, unknown>>;
|
|
68
70
|
/**
|
package/dist/protocol/tools.js
CHANGED
|
@@ -17,6 +17,10 @@ export const OPENCODE_1_TOOL_DIALECT = {
|
|
|
17
17
|
filePathKey: "filePath",
|
|
18
18
|
shellTool: "bash",
|
|
19
19
|
};
|
|
20
|
+
export const OPENCODE_2_TOOL_DIALECT = {
|
|
21
|
+
filePathKey: "path",
|
|
22
|
+
shellTool: "shell",
|
|
23
|
+
};
|
|
20
24
|
function jsonSchemaProperties(schema) {
|
|
21
25
|
if (!schema || typeof schema !== "object" || Array.isArray(schema))
|
|
22
26
|
return undefined;
|
|
@@ -24,11 +28,11 @@ function jsonSchemaProperties(schema) {
|
|
|
24
28
|
if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
|
|
25
29
|
return obj.properties;
|
|
26
30
|
}
|
|
27
|
-
const nested = obj.jsonSchema;
|
|
31
|
+
const nested = obj.jsonSchema ?? obj.parameters ?? obj.schema;
|
|
28
32
|
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
29
|
-
const
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
33
|
+
const nestedProps = nested.properties;
|
|
34
|
+
if (nestedProps && typeof nestedProps === "object" && !Array.isArray(nestedProps)) {
|
|
35
|
+
return nestedProps;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
return undefined;
|
|
@@ -43,7 +47,7 @@ export function opencodePathArg(args) {
|
|
|
43
47
|
* Infer the host tool dialect from advertised AI SDK schemas.
|
|
44
48
|
* OpenCode 2.0 read/edit/write require `path` and rename bash → `shell`.
|
|
45
49
|
*/
|
|
46
|
-
export function hostToolDialectFromTools(tools) {
|
|
50
|
+
export function hostToolDialectFromTools(tools, defaultDialect = OPENCODE_1_TOOL_DIALECT) {
|
|
47
51
|
let filePathKey;
|
|
48
52
|
for (const name of ["read", "write", "edit"]) {
|
|
49
53
|
const tool = tools.find((candidate) => candidate.name === name);
|
|
@@ -60,10 +64,12 @@ export function hostToolDialectFromTools(tools) {
|
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
66
|
const names = new Set(tools.map((tool) => tool.name).filter((name) => typeof name === "string"));
|
|
63
|
-
const shellTool = names.has("shell") && !names.has("bash")
|
|
67
|
+
const shellTool = names.has("shell") && !names.has("bash")
|
|
68
|
+
? "shell"
|
|
69
|
+
: (names.has("bash") ? "bash" : defaultDialect.shellTool);
|
|
64
70
|
if (!filePathKey) {
|
|
65
71
|
// OpenCode 2 hosts advertise `shell` and `path` together; use that when schemas are opaque.
|
|
66
|
-
filePathKey = shellTool === "shell" ? "path" :
|
|
72
|
+
filePathKey = shellTool === "shell" ? "path" : defaultDialect.filePathKey;
|
|
67
73
|
}
|
|
68
74
|
return { filePathKey, shellTool };
|
|
69
75
|
}
|
|
@@ -115,12 +121,14 @@ export function resolveToolServerIdentity(opencodeName, defaultServer = "opencod
|
|
|
115
121
|
* default server (`opencode`). Tools whose prefixes match configured MCP
|
|
116
122
|
* servers keep those server ids (`github`, …). Composite `name` is
|
|
117
123
|
* `<server>-<bareTool>`; local execution still uses the full OpenCode id
|
|
118
|
-
* reconstructed in `mcpRealToolName`.
|
|
124
|
+
* reconstructed in `mcpRealToolName`. Emit in advertised order — the epoch
|
|
125
|
+
* catalog owns canonicalization; sorting here would insert on grow.
|
|
119
126
|
*/
|
|
120
127
|
export function toolsToDescriptors(tools, providerIdentifier = "opencode", knownMcpServers = []) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
// Advertised order is the prefix. The epoch catalog sorts once on first
|
|
129
|
+
// freeze and appends later names at the tail; re-sorting here would insert
|
|
130
|
+
// a new tool in front of `z` and invalidate the tools bytes.
|
|
131
|
+
return tools.map((t) => {
|
|
124
132
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
125
133
|
const collisionSafeAlias = COLLISION_SAFE_ALIASES.has(t.name);
|
|
126
134
|
return {
|
|
@@ -233,11 +241,10 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
|
|
|
233
241
|
if (tools.length === 0)
|
|
234
242
|
return [];
|
|
235
243
|
const byServer = new Map();
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
for (const t of [...tools].sort((left, right) => left.name.localeCompare(right.name))) {
|
|
244
|
+
// Walk advertised order: first-seen server, then append tools onto that
|
|
245
|
+
// server. Same-set host reorder is resolved by `resolveTurnToolState`
|
|
246
|
+
// before encode so this walk stays epoch-stable.
|
|
247
|
+
for (const t of tools) {
|
|
241
248
|
const id = resolveToolServerIdentity(t.sourceName ?? t.name, providerIdentifier, knownMcpServers);
|
|
242
249
|
let list = byServer.get(id.server);
|
|
243
250
|
if (!list) {
|
|
@@ -250,14 +257,7 @@ export function toolsToMcpDescriptors(tools, providerIdentifier = "opencode", kn
|
|
|
250
257
|
input_schema: encodeJsonAsValue(normalizeInputSchema(t.inputSchema)),
|
|
251
258
|
});
|
|
252
259
|
}
|
|
253
|
-
|
|
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) => ({
|
|
260
|
+
return [...byServer.keys()].map((server) => ({
|
|
261
261
|
server_name: server,
|
|
262
262
|
server_identifier: server,
|
|
263
263
|
tools: byServer.get(server),
|
|
@@ -872,6 +872,20 @@ function readRequestResultMetadata(raw, mappedArgs) {
|
|
|
872
872
|
...(typeof mappedArgs.limit === "number" ? { limit: mappedArgs.limit } : {}),
|
|
873
873
|
};
|
|
874
874
|
}
|
|
875
|
+
/**
|
|
876
|
+
* Cursor's native write executor echoes `WriteArgs.path` on `WriteSuccess.path`.
|
|
877
|
+
* Large GetMcpTools / GetDynamicTools catalogs spill through `write_args` into
|
|
878
|
+
* `agent-tools/<uuid>.txt` and then tell the model `filePath` from that success
|
|
879
|
+
* path. Host write output is free-form prose (often no `<path>` tag), so the
|
|
880
|
+
* request path is the only host-neutral identity we can report.
|
|
881
|
+
*/
|
|
882
|
+
function writeRequestResultMetadata(raw, mappedArgs) {
|
|
883
|
+
const requestedPath = str(raw.path)
|
|
884
|
+
?? str(raw.filePath)
|
|
885
|
+
?? str(raw.file_path)
|
|
886
|
+
?? opencodePathArg(mappedArgs);
|
|
887
|
+
return requestedPath ? { path: requestedPath } : undefined;
|
|
888
|
+
}
|
|
875
889
|
/**
|
|
876
890
|
* A partial-read notice is for Cursor's model, never for a file. Refuse only
|
|
877
891
|
* whole-file mutation forms that echo it. A targeted edit or Update File patch
|
|
@@ -1049,9 +1063,11 @@ export function parseExecServerMessage(msg, dialect = OPENCODE_1_TOOL_DIALECT) {
|
|
|
1049
1063
|
? shellStreamResultMetadata(rawArgs)
|
|
1050
1064
|
: execVariant === "read_args" || execVariant === "pi_read_args"
|
|
1051
1065
|
? readRequestResultMetadata(rawArgs, mapped.args)
|
|
1052
|
-
: execVariant === "
|
|
1053
|
-
?
|
|
1054
|
-
:
|
|
1066
|
+
: execVariant === "write_args" || execVariant === "pi_write_args"
|
|
1067
|
+
? writeRequestResultMetadata(rawArgs, mapped.args)
|
|
1068
|
+
: execVariant === "grep_args"
|
|
1069
|
+
? grepRequestResultMetadata(rawArgs)
|
|
1070
|
+
: undefined;
|
|
1055
1071
|
if (resultMetadata
|
|
1056
1072
|
&& resultMetadata.timeout_behavior !== 2
|
|
1057
1073
|
&& typeof resultMetadata.timeout_ms === "number") {
|
|
@@ -1168,7 +1184,9 @@ export function mapCursorArgsToOpencode(toolName, raw, execVariant, dialect = OP
|
|
|
1168
1184
|
else {
|
|
1169
1185
|
content = stringValue(cleaned.content)
|
|
1170
1186
|
?? stringValue(cleaned.file_text)
|
|
1171
|
-
?? stringValue(cleaned.fileText)
|
|
1187
|
+
?? stringValue(cleaned.fileText)
|
|
1188
|
+
?? stringValue(cleaned.contents)
|
|
1189
|
+
?? stringValue(cleaned.text);
|
|
1172
1190
|
}
|
|
1173
1191
|
if (content !== undefined)
|
|
1174
1192
|
args.content = content;
|
|
@@ -1919,6 +1937,15 @@ function toolPathSeparator(filePath) {
|
|
|
1919
1937
|
* roots intact, including `..`, and still use `path.resolve` for host paths.
|
|
1920
1938
|
*/
|
|
1921
1939
|
function joinToolPath(root, relative) {
|
|
1940
|
+
// `path.resolve` drops a trailing separator. Glob and find use that separator
|
|
1941
|
+
// to tell a directory from a file; putting it back keeps the marker.
|
|
1942
|
+
const directory = relative.endsWith("/") || relative.endsWith("\\");
|
|
1943
|
+
const joined = joinResolvedToolPath(root, relative);
|
|
1944
|
+
if (!directory || joined.endsWith("/") || joined.endsWith("\\"))
|
|
1945
|
+
return joined;
|
|
1946
|
+
return `${joined}${toolPathSeparator(joined)}`;
|
|
1947
|
+
}
|
|
1948
|
+
function joinResolvedToolPath(root, relative) {
|
|
1922
1949
|
if (!isForeignAbsoluteToolPath(root))
|
|
1923
1950
|
return path.resolve(root, relative);
|
|
1924
1951
|
const sep = toolPathSeparator(root);
|
|
@@ -1988,17 +2015,169 @@ function resolveToolPath(filePath, workspaceRoot) {
|
|
|
1988
2015
|
* paths, indented match previews, and prose stay untouched. Grep file headers
|
|
1989
2016
|
* drop the trailing colon only in the structured file list, not in this text.
|
|
1990
2017
|
*/
|
|
2018
|
+
/**
|
|
2019
|
+
* Longer phrases first. "No files found" is a prefix of the pattern sentence,
|
|
2020
|
+
* and a suffix check must not stop on the shorter one.
|
|
2021
|
+
*/
|
|
2022
|
+
const SEARCH_STATUS_MESSAGES = [
|
|
2023
|
+
"No files found matching pattern",
|
|
2024
|
+
"No matches before timeout (scan incomplete)",
|
|
2025
|
+
"No files found",
|
|
2026
|
+
"No matches found",
|
|
2027
|
+
];
|
|
2028
|
+
/**
|
|
2029
|
+
* A glob/find miss is prose, not a path. Hosts sometimes still carry it as a
|
|
2030
|
+
* path segment (`../../workspace/No files found matching pattern`) or fold that
|
|
2031
|
+
* segment into a `# dir/` header plus a bare sentence. Either shape must stay
|
|
2032
|
+
* the sentence: joining it onto the workspace is what lists a fake file.
|
|
2033
|
+
*/
|
|
2034
|
+
function searchStatusMessage(line) {
|
|
2035
|
+
const trimmed = line.trim().replace(/^#+\s+/, "");
|
|
2036
|
+
for (const message of SEARCH_STATUS_MESSAGES) {
|
|
2037
|
+
if (trimmed === message || trimmed.endsWith(`/${message}`) || trimmed.endsWith(`\\${message}`)) {
|
|
2038
|
+
return message;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return undefined;
|
|
2042
|
+
}
|
|
2043
|
+
function collapseSearchStatus(output) {
|
|
2044
|
+
return output.split("\n").map((line) => searchStatusMessage(line) ?? line).join("\n");
|
|
2045
|
+
}
|
|
2046
|
+
function isSearchStatusReport(output) {
|
|
2047
|
+
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2048
|
+
return lines.length > 0 && lines.every((line) => searchStatusMessage(line) !== undefined);
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Cursor's find/glob executor shows one path per line. A directory ends with
|
|
2052
|
+
* `/`; a file does not. Some hosts instead print a folded tree:
|
|
2053
|
+
*
|
|
2054
|
+
* # src/
|
|
2055
|
+
* a.ts
|
|
2056
|
+
* ## components/
|
|
2057
|
+
* button.tsx
|
|
2058
|
+
* # tests/
|
|
2059
|
+
*
|
|
2060
|
+
* A `# dir/` line is a grouping header, not another match. Emitting it lists
|
|
2061
|
+
* the directory beside its own children (and the shared search prefix shows up
|
|
2062
|
+
* as a blank entry once that prefix is the directory being searched). Expand
|
|
2063
|
+
* the tree to the files underneath only. A header with no child is still just
|
|
2064
|
+
* an empty directory in the walk — Cursor find reports files, so leave it out
|
|
2065
|
+
* (same as targeting that empty directory and getting a miss).
|
|
2066
|
+
*/
|
|
2067
|
+
function parseGroupedPathListing(output) {
|
|
2068
|
+
const lines = output.replace(/\n+$/, "").split("\n");
|
|
2069
|
+
const events = [];
|
|
2070
|
+
const stack = [];
|
|
2071
|
+
let sawHeader = false;
|
|
2072
|
+
let inNotes = false;
|
|
2073
|
+
for (const line of lines) {
|
|
2074
|
+
if (!line.trim()) {
|
|
2075
|
+
if (sawHeader)
|
|
2076
|
+
inNotes = true;
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
if (inNotes) {
|
|
2080
|
+
events.push({ kind: "note", text: line });
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
const status = searchStatusMessage(line);
|
|
2084
|
+
if (status) {
|
|
2085
|
+
events.push({ kind: "note", text: status });
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
const header = /^(#+)\s+(\S.*?)\s*$/.exec(line);
|
|
2089
|
+
if (header) {
|
|
2090
|
+
const rawName = header[2] ?? "";
|
|
2091
|
+
if (!rawName.endsWith("/") && !rawName.endsWith("\\"))
|
|
2092
|
+
return undefined;
|
|
2093
|
+
const depth = header[1].length - 1;
|
|
2094
|
+
if (depth > stack.length)
|
|
2095
|
+
return undefined;
|
|
2096
|
+
const name = rawName.slice(0, -1);
|
|
2097
|
+
if (!name || name === "." || name === "..")
|
|
2098
|
+
return undefined;
|
|
2099
|
+
stack.length = depth;
|
|
2100
|
+
const parent = depth === 0 ? "" : (stack[depth - 1] ?? "");
|
|
2101
|
+
if (depth > 0 && !parent)
|
|
2102
|
+
return undefined;
|
|
2103
|
+
const full = parent ? joinDisplayPath(parent, name) : name;
|
|
2104
|
+
stack.push(full);
|
|
2105
|
+
events.push({ kind: "dir", depth, path: full });
|
|
2106
|
+
sawHeader = true;
|
|
2107
|
+
continue;
|
|
2108
|
+
}
|
|
2109
|
+
if (line.startsWith(" ") || line.startsWith("\t") || /\s/.test(line) || line.includes("://")) {
|
|
2110
|
+
return undefined;
|
|
2111
|
+
}
|
|
2112
|
+
if (line.includes("/") || line.includes("\\"))
|
|
2113
|
+
return undefined;
|
|
2114
|
+
if (!sawHeader) {
|
|
2115
|
+
events.push({ kind: "file", path: line });
|
|
2116
|
+
continue;
|
|
2117
|
+
}
|
|
2118
|
+
const parent = stack[stack.length - 1];
|
|
2119
|
+
if (!parent)
|
|
2120
|
+
return undefined;
|
|
2121
|
+
events.push({ kind: "file", path: joinDisplayPath(parent, line) });
|
|
2122
|
+
}
|
|
2123
|
+
if (!sawHeader)
|
|
2124
|
+
return undefined;
|
|
2125
|
+
const paths = [];
|
|
2126
|
+
const notes = [];
|
|
2127
|
+
let sawFile = false;
|
|
2128
|
+
for (const event of events) {
|
|
2129
|
+
if (event.kind === "note")
|
|
2130
|
+
notes.push(event.text);
|
|
2131
|
+
else if (event.kind === "file") {
|
|
2132
|
+
sawFile = true;
|
|
2133
|
+
paths.push(event.path);
|
|
2134
|
+
}
|
|
2135
|
+
// Directory headers are structure only — never emit them as matches.
|
|
2136
|
+
}
|
|
2137
|
+
// The header existed only to hold the miss sentence. Emitting it lists a
|
|
2138
|
+
// directory that was never a match, usually the walk back to the workspace.
|
|
2139
|
+
if (!sawFile && notes.some((note) => searchStatusMessage(note)))
|
|
2140
|
+
return { paths: [], notes };
|
|
2141
|
+
return { paths, notes };
|
|
2142
|
+
}
|
|
2143
|
+
function joinDisplayPath(parent, child) {
|
|
2144
|
+
if (!parent || parent === ".")
|
|
2145
|
+
return child;
|
|
2146
|
+
if (parent === "/")
|
|
2147
|
+
return `/${child}`;
|
|
2148
|
+
const sep = parent.includes("\\") && !parent.includes("/") ? "\\" : "/";
|
|
2149
|
+
if (parent.endsWith("/") || parent.endsWith("\\"))
|
|
2150
|
+
return `${parent}${child}`;
|
|
2151
|
+
return `${parent}${sep}${child}`;
|
|
2152
|
+
}
|
|
2153
|
+
function renderGroupedPathListing(listing, workspaceRoot) {
|
|
2154
|
+
const paths = listing.paths.map((entry) => resolveToolPath(entry, workspaceRoot));
|
|
2155
|
+
if (paths.length === 0) {
|
|
2156
|
+
// Grouped output that only had directory headers (empty dirs) is a miss,
|
|
2157
|
+
// same as targeting that empty directory directly.
|
|
2158
|
+
if (listing.notes.length === 0)
|
|
2159
|
+
return "No files found";
|
|
2160
|
+
return listing.notes.join("\n");
|
|
2161
|
+
}
|
|
2162
|
+
if (listing.notes.length === 0)
|
|
2163
|
+
return paths.join("\n");
|
|
2164
|
+
return [...paths, "", ...listing.notes].join("\n");
|
|
2165
|
+
}
|
|
1991
2166
|
function groundSearchOutput(output, workspaceRoot) {
|
|
1992
|
-
if (!workspaceRoot)
|
|
1993
|
-
return output;
|
|
1994
2167
|
const normalized = normalizeToolText(output);
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
if (
|
|
2000
|
-
return
|
|
2001
|
-
|
|
2168
|
+
const grouped = parseGroupedPathListing(normalized);
|
|
2169
|
+
if (grouped)
|
|
2170
|
+
return renderGroupedPathListing(grouped, workspaceRoot);
|
|
2171
|
+
const collapsed = collapseSearchStatus(normalized);
|
|
2172
|
+
if (isSearchStatusReport(collapsed))
|
|
2173
|
+
return collapsed;
|
|
2174
|
+
if (!workspaceRoot)
|
|
2175
|
+
return collapsed;
|
|
2176
|
+
const first = collapsed.split("\n", 1)[0] ?? "";
|
|
2177
|
+
const searchShaped = /^Found \d+ matches/.test(first) || searchStatusMessage(first) !== undefined;
|
|
2178
|
+
if (!searchShaped && !isBarePathList(collapsed))
|
|
2179
|
+
return collapsed;
|
|
2180
|
+
return collapsed.split("\n").map((line) => rewriteSearchPathLine(line, workspaceRoot)).join("\n");
|
|
2002
2181
|
}
|
|
2003
2182
|
function isBarePathList(output) {
|
|
2004
2183
|
const lines = output.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
@@ -2015,8 +2194,9 @@ function isBarePathList(output) {
|
|
|
2015
2194
|
function rewriteSearchPathLine(line, workspaceRoot) {
|
|
2016
2195
|
if (!line || line.startsWith(" ") || line.startsWith("\t") || line.startsWith("("))
|
|
2017
2196
|
return line;
|
|
2018
|
-
|
|
2019
|
-
|
|
2197
|
+
const status = searchStatusMessage(line);
|
|
2198
|
+
if (status || line.startsWith("Found "))
|
|
2199
|
+
return status ?? line;
|
|
2020
2200
|
const header = /^(.*):$/.exec(line);
|
|
2021
2201
|
if (header && !header[1]?.includes("://")) {
|
|
2022
2202
|
return `${resolveToolPath(header[1] ?? "", workspaceRoot)}:`;
|
|
@@ -2131,20 +2311,33 @@ function grepRequestResultMetadata(raw) {
|
|
|
2131
2311
|
}
|
|
2132
2312
|
function extractGroundedPaths(output, workspaceRoot) {
|
|
2133
2313
|
const normalized = normalizeToolText(output);
|
|
2134
|
-
const
|
|
2314
|
+
const grouped = parseGroupedPathListing(normalized);
|
|
2315
|
+
if (grouped) {
|
|
2316
|
+
return grouped.paths.map((entry) => resolveToolPath(entry, workspaceRoot)).slice(0, 2000);
|
|
2317
|
+
}
|
|
2318
|
+
const collapsed = collapseSearchStatus(normalized);
|
|
2319
|
+
if (isSearchStatusReport(collapsed))
|
|
2320
|
+
return [];
|
|
2321
|
+
const directory = parseOpenCode2DirectoryListing(collapsed, workspaceRoot);
|
|
2135
2322
|
if (directory)
|
|
2136
2323
|
return directory.entries.slice(0, 2000);
|
|
2137
|
-
const first =
|
|
2138
|
-
if (/^Found \d+ matches/.test(first) || first
|
|
2324
|
+
const first = collapsed.split("\n", 1)[0]?.trim() ?? "";
|
|
2325
|
+
if (/^Found \d+ matches/.test(first) || searchStatusMessage(first)) {
|
|
2139
2326
|
const paths = [];
|
|
2140
|
-
for (const raw of
|
|
2327
|
+
for (const raw of collapsed.split("\n").slice(1)) {
|
|
2141
2328
|
const line = raw.trim();
|
|
2142
|
-
if (!line
|
|
2329
|
+
if (!line
|
|
2330
|
+
|| line.startsWith("(")
|
|
2331
|
+
|| line.startsWith("Line ")
|
|
2332
|
+
|| line.startsWith("#")
|
|
2333
|
+
|| searchStatusMessage(line)
|
|
2334
|
+
|| raw.startsWith(" ")
|
|
2335
|
+
|| raw.startsWith("\t")) {
|
|
2143
2336
|
continue;
|
|
2144
2337
|
}
|
|
2145
2338
|
const header = /^(.*):$/.exec(line);
|
|
2146
2339
|
const candidate = header && !header[1]?.includes("://") ? (header[1] ?? "") : line;
|
|
2147
|
-
if (!candidate || candidate.includes("://"))
|
|
2340
|
+
if (!candidate || candidate.includes("://") || searchStatusMessage(candidate))
|
|
2148
2341
|
continue;
|
|
2149
2342
|
paths.push(resolveToolPath(candidate, workspaceRoot));
|
|
2150
2343
|
}
|
|
@@ -2348,8 +2541,9 @@ export function buildTypedExecResult(resultField, output, error, toolName, resul
|
|
|
2348
2541
|
};
|
|
2349
2542
|
}
|
|
2350
2543
|
case "write_result": {
|
|
2351
|
-
//
|
|
2352
|
-
//
|
|
2544
|
+
// Prefer the path recorded from WriteArgs / a remapped apply_patch write.
|
|
2545
|
+
// Host write output is not a `<path>` envelope, and Cursor's GetMcpTools
|
|
2546
|
+
// spill copies WriteSuccess.path into the model-visible filePath.
|
|
2353
2547
|
const remappedPath = str(resultMetadata?.path);
|
|
2354
2548
|
if (error)
|
|
2355
2549
|
return { error: { path: remappedPath ?? "", error } };
|
|
@@ -2877,10 +3071,12 @@ function readFileLineCount(filePath) {
|
|
|
2877
3071
|
}
|
|
2878
3072
|
}
|
|
2879
3073
|
function extractPathLines(output) {
|
|
2880
|
-
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
2881
|
-
//
|
|
2882
|
-
|
|
2883
|
-
|
|
3074
|
+
const lines = collapseSearchStatus(output).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
3075
|
+
// A folded `# dir/` header is not itself a path. Prefer absolute / relative
|
|
3076
|
+
// path-looking lines; fall back to remaining non-status lines.
|
|
3077
|
+
const usable = lines.filter((line) => !searchStatusMessage(line) && !/^#+\s+\S/.test(line));
|
|
3078
|
+
const paths = usable.filter((l) => l.startsWith("/") || l.startsWith("./") || l.includes("/"));
|
|
3079
|
+
return (paths.length > 0 ? paths : usable).slice(0, 2000);
|
|
2884
3080
|
}
|
|
2885
3081
|
function countLines(s) {
|
|
2886
3082
|
if (!s)
|