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-utils"; import { resolveToCwd } from "./tools/path-utils"; interface CursorExecBridgeOptions { cwd: string; tools: Map; getToolContext?: () => AgentToolContext | undefined; emitEvent?: (event: AgentEvent) => void; } function createToolResultMessage( toolCallId: string, toolName: string, result: AgentToolResult, isError: boolean, ): ToolResultMessage { return { role: "toolResult", toolCallId, toolName, content: result.content, details: result.details, isError, timestamp: Date.now(), }; } function buildToolErrorResult(message: string): AgentToolResult { return { content: [{ type: "text", text: message }], details: {}, }; } async function executeTool( options: CursorExecBridgeOptions, toolName: string, toolCallId: string, args: Record, ): Promise { 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; let isError = false; const onUpdate: AgentToolUpdateCallback | undefined = options.emitEvent ? partialResult => { const sanitizedResult: AgentToolResult = { 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, undefined, onUpdate, options.getToolContext?.(), ); } catch (error) { const message = error instanceof Error ? error.message : String(error); result = buildToolErrorResult(message); isError = true; } const sanitizedFinalResult: AgentToolResult = { 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; 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): Record { const decoded: Record = {}; 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>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path }); return toolResultMessage; } async ls(args: Parameters>[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>[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>[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>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeDelete(this.options, args.path, toolCallId); return toolResultMessage; } async shell(args: Parameters>[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>[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 = { command: args.command, cwd: args.workingDirectory || undefined, timeout: timeoutSeconds, }; this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs }); let result: AgentToolResult; let isError = false; let rawText = ""; let sanitizedRawText = ""; let streamedSanitizedText = ""; let canStreamSanitizedDelta = true; const onUpdate: AgentToolUpdateCallback = 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 = { 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 = { 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>[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; } }