f0e9a830ff
- Switched search/ast_grep/ast_edit/find inputs from scalar path fields to required `paths` arrays. - Reworked path resolution to normalize and expand each `paths` entry via explicit helpers in `src/tools/path-utils.ts`. - Updated search and find tool-call rendering to display explicit `paths` values in mode overlays and export views. - Updated tool prompt docs and examples to document `paths` array inputs for search, grep, find, and AST tools. - Raised the default search match limit from 20 to 500 and updated limit-reached messaging.
341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import * as fs from "node:fs";
|
|
import type {
|
|
AgentEvent,
|
|
AgentTool,
|
|
AgentToolContext,
|
|
AgentToolResult,
|
|
AgentToolUpdateCallback,
|
|
} from "@oh-my-pi/pi-agent-core";
|
|
import type {
|
|
CursorMcpCall,
|
|
CursorShellStreamCallbacks,
|
|
CursorExecHandlers as ICursorExecHandlers,
|
|
ToolResultMessage,
|
|
} from "@oh-my-pi/pi-ai";
|
|
import { sanitizeText } from "@oh-my-pi/pi-natives";
|
|
import { resolveToCwd } from "./tools/path-utils";
|
|
|
|
interface CursorExecBridgeOptions {
|
|
cwd: string;
|
|
tools: Map<string, AgentTool>;
|
|
getToolContext?: () => AgentToolContext | undefined;
|
|
emitEvent?: (event: AgentEvent) => void;
|
|
}
|
|
|
|
function createToolResultMessage(
|
|
toolCallId: string,
|
|
toolName: string,
|
|
result: AgentToolResult<unknown>,
|
|
isError: boolean,
|
|
): ToolResultMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId,
|
|
toolName,
|
|
content: result.content,
|
|
details: result.details,
|
|
isError,
|
|
timestamp: Date.now(),
|
|
};
|
|
}
|
|
|
|
function buildToolErrorResult(message: string): AgentToolResult<unknown> {
|
|
return {
|
|
content: [{ type: "text", text: message }],
|
|
details: {},
|
|
};
|
|
}
|
|
|
|
async function executeTool(
|
|
options: CursorExecBridgeOptions,
|
|
toolName: string,
|
|
toolCallId: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<ToolResultMessage> {
|
|
const tool = options.tools.get(toolName);
|
|
if (!tool) {
|
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args });
|
|
|
|
let result: AgentToolResult<unknown>;
|
|
let isError = false;
|
|
|
|
const onUpdate: AgentToolUpdateCallback<unknown> | undefined = options.emitEvent
|
|
? partialResult => {
|
|
const sanitizedResult: AgentToolResult<unknown> = {
|
|
content: partialResult.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: partialResult.details,
|
|
};
|
|
options.emitEvent?.({
|
|
type: "tool_execution_update",
|
|
toolCallId,
|
|
toolName,
|
|
args,
|
|
partialResult: sanitizedResult,
|
|
});
|
|
}
|
|
: undefined;
|
|
|
|
try {
|
|
result = await tool.execute(
|
|
toolCallId,
|
|
args as Record<string, unknown>,
|
|
undefined,
|
|
onUpdate,
|
|
options.getToolContext?.(),
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
|
|
const sanitizedFinalResult: AgentToolResult<unknown> = {
|
|
content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: result.details,
|
|
};
|
|
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result: sanitizedFinalResult, isError });
|
|
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) {
|
|
const toolName = "delete";
|
|
options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } });
|
|
|
|
const absolutePath = resolveToCwd(pathArg, options.cwd);
|
|
let isError = false;
|
|
let result: AgentToolResult<unknown>;
|
|
|
|
try {
|
|
let fileStat: fs.Stats | undefined;
|
|
try {
|
|
fileStat = fs.statSync(absolutePath);
|
|
} catch {
|
|
throw new Error(`File not found: ${pathArg}`);
|
|
}
|
|
if (!fileStat.isFile()) {
|
|
throw new Error(`Path is not a file: ${pathArg}`);
|
|
}
|
|
|
|
fs.rmSync(absolutePath);
|
|
|
|
const sizeText = fileStat.size ? ` (${fileStat.size} bytes)` : "";
|
|
const message = `Deleted ${pathArg}${sizeText}`;
|
|
result = { content: [{ type: "text", text: message }], details: {} };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
|
|
options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError });
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
function decodeToolCallId(toolCallId?: string): string {
|
|
return toolCallId && toolCallId.length > 0 ? toolCallId : randomUUID();
|
|
}
|
|
|
|
function decodeMcpArgs(rawArgs: Record<string, Uint8Array>): Record<string, unknown> {
|
|
const decoded: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(rawArgs)) {
|
|
const text = new TextDecoder().decode(value);
|
|
try {
|
|
decoded[key] = JSON.parse(text);
|
|
} catch {
|
|
decoded[key] = text;
|
|
}
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
function formatMcpToolErrorMessage(toolName: string, availableTools: string[]): string {
|
|
const list = availableTools.length > 0 ? availableTools.join(", ") : "none";
|
|
return `MCP tool "${toolName}" not found. Available tools: ${list}`;
|
|
}
|
|
|
|
export class CursorExecHandlers implements ICursorExecHandlers {
|
|
constructor(private options: CursorExecBridgeOptions) {}
|
|
|
|
async read(args: Parameters<NonNullable<ICursorExecHandlers["read"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async ls(args: Parameters<NonNullable<ICursorExecHandlers["ls"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
// Redirect ls to read tool, which handles directories
|
|
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async grep(args: Parameters<NonNullable<ICursorExecHandlers["grep"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
|
|
const toolResultMessage = await executeTool(this.options, "search", toolCallId, {
|
|
pattern: args.pattern,
|
|
paths: [searchPath],
|
|
i: args.caseInsensitive || undefined,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async write(args: Parameters<NonNullable<ICursorExecHandlers["write"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
|
|
const toolResultMessage = await executeTool(this.options, "write", toolCallId, {
|
|
path: args.path,
|
|
content,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async delete(args: Parameters<NonNullable<ICursorExecHandlers["delete"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeDelete(this.options, args.path, toolCallId);
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async shell(args: Parameters<NonNullable<ICursorExecHandlers["shell"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
const toolResultMessage = await executeTool(this.options, "bash", toolCallId, {
|
|
command: args.command,
|
|
cwd: args.workingDirectory || undefined,
|
|
timeout: timeoutSeconds,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async shellStream(
|
|
args: Parameters<NonNullable<ICursorExecHandlers["shellStream"]>>[0],
|
|
callbacks: CursorShellStreamCallbacks,
|
|
) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolName = "bash";
|
|
const tool = this.options.tools.get(toolName);
|
|
if (!tool) {
|
|
const result = buildToolErrorResult(`Tool "${toolName}" not available`);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined;
|
|
const toolArgs: Record<string, unknown> = {
|
|
command: args.command,
|
|
cwd: args.workingDirectory || undefined,
|
|
timeout: timeoutSeconds,
|
|
};
|
|
|
|
this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs });
|
|
|
|
let result: AgentToolResult<unknown>;
|
|
let isError = false;
|
|
|
|
let rawText = "";
|
|
let sanitizedRawText = "";
|
|
let streamedSanitizedText = "";
|
|
let canStreamSanitizedDelta = true;
|
|
const onUpdate: AgentToolUpdateCallback<unknown> = partialResult => {
|
|
const newRawText = partialResult.content.map(c => (c.type === "text" ? c.text : "")).join("");
|
|
if (newRawText === rawText) {
|
|
return;
|
|
}
|
|
rawText = newRawText;
|
|
sanitizedRawText = sanitizeText(newRawText);
|
|
const sanitizedPartialResult: AgentToolResult<unknown> = {
|
|
content: [{ type: "text" as const, text: sanitizedRawText }],
|
|
details: partialResult.details,
|
|
};
|
|
this.options.emitEvent?.({
|
|
type: "tool_execution_update",
|
|
toolCallId,
|
|
toolName,
|
|
args: toolArgs,
|
|
partialResult: sanitizedPartialResult,
|
|
});
|
|
if (!canStreamSanitizedDelta) {
|
|
return;
|
|
}
|
|
if (sanitizedRawText.startsWith(streamedSanitizedText)) {
|
|
const sanitizedDelta = sanitizedRawText.slice(streamedSanitizedText.length);
|
|
streamedSanitizedText = sanitizedRawText;
|
|
if (sanitizedDelta) {
|
|
callbacks.onStdout(sanitizedDelta);
|
|
}
|
|
return;
|
|
}
|
|
// Cursor's shell-stream callback is append-only. Once the sanitized snapshot
|
|
// stops being a prefix extension, we can no longer repair the stream safely.
|
|
// Keep emitting full snapshots via tool_execution_update, but stop stdout deltas.
|
|
canStreamSanitizedDelta = false;
|
|
};
|
|
|
|
try {
|
|
result = await tool.execute(toolCallId, toolArgs, undefined, onUpdate, this.options.getToolContext?.());
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
result = buildToolErrorResult(message);
|
|
isError = true;
|
|
}
|
|
|
|
// onUpdate may not fire for every chunk — flush any remaining output
|
|
// from the final result that wasn't already streamed.
|
|
const finalRawText = result.content.map(c => (c.type === "text" ? c.text : "")).join("");
|
|
if (finalRawText !== rawText) {
|
|
rawText = finalRawText;
|
|
sanitizedRawText = sanitizeText(finalRawText);
|
|
}
|
|
if (canStreamSanitizedDelta && sanitizedRawText.startsWith(streamedSanitizedText)) {
|
|
const finalDelta = sanitizedRawText.slice(streamedSanitizedText.length);
|
|
streamedSanitizedText = sanitizedRawText;
|
|
if (finalDelta) {
|
|
callbacks.onStdout(finalDelta);
|
|
}
|
|
}
|
|
|
|
const sanitizedFinalResult: AgentToolResult<unknown> = {
|
|
content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
|
|
details: result.details,
|
|
};
|
|
this.options.emitEvent?.({
|
|
type: "tool_execution_end",
|
|
toolCallId,
|
|
toolName,
|
|
result: sanitizedFinalResult,
|
|
isError,
|
|
});
|
|
return createToolResultMessage(toolCallId, toolName, result, isError);
|
|
}
|
|
|
|
async diagnostics(args: Parameters<NonNullable<ICursorExecHandlers["diagnostics"]>>[0]) {
|
|
const toolCallId = decodeToolCallId(args.toolCallId);
|
|
const toolResultMessage = await executeTool(this.options, "lsp", toolCallId, {
|
|
action: "diagnostics",
|
|
file: args.path,
|
|
});
|
|
return toolResultMessage;
|
|
}
|
|
|
|
async mcp(call: CursorMcpCall) {
|
|
const toolName = call.toolName || call.name;
|
|
const toolCallId = decodeToolCallId(call.toolCallId);
|
|
const tool = this.options.tools.get(toolName);
|
|
if (!tool) {
|
|
const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
|
|
const message = formatMcpToolErrorMessage(toolName, availableTools);
|
|
const result = buildToolErrorResult(message);
|
|
return createToolResultMessage(toolCallId, toolName, result, true);
|
|
}
|
|
|
|
const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {});
|
|
const toolResultMessage = await executeTool(this.options, toolName, toolCallId, args);
|
|
return toolResultMessage;
|
|
}
|
|
}
|