import { spawn } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; type DiscoveredModel = { id: string; name: string; }; type ExecResult = { stdout: string; stderr: string; code: number; }; type RuntimeState = { server: ReturnType; baseUrl: string; workspace: string; models: DiscoveredModel[]; }; type StreamJsonEvent = { type?: string; subtype?: string; timestamp_ms?: number; call_id?: string; message?: { role?: string; content?: Array<{ type?: string; text?: string; thinking?: string }>; }; tool_call?: Record; result?: unknown }>; }; type OpenAiToolCall = { id: string; type: "function"; function: { name: string; arguments: string; }; }; const PROVIDER_ID = "cursor-acp"; const PROVIDER_NAME = "Cursor ACP"; const HOST = "127.0.0.1"; const LOGIN_URL_TIMEOUT_MS = 15_000; const AUTH_POLL_INTERVAL_MS = 2_000; const AUTH_POLL_TIMEOUT_MS = 5 * 60_000; const CURSOR_FORCE = process.env.PI_CURSOR_ACP_FORCE !== "false"; /** Same resolution as community providers: https://github.com/netandreus/pi-cursor-provider */ const CURSOR_AGENT_CMD = process.env.PI_CURSOR_AGENT_PATH ?? process.env.CURSOR_AGENT_PATH ?? process.env.AGENT_PATH ?? "cursor-agent"; const TOOL_NAME_ALIASES = new Map([ ["readtoolcall", "read"], ["readfile", "read"], ["read", "read"], ["writetoolcall", "write"], ["writefile", "write"], ["write", "write"], ["edittoolcall", "edit"], ["editfile", "edit"], ["edit", "edit"], ["bashtoolcall", "bash"], ["bash", "bash"], ["runcommand", "bash"], ["executecommand", "bash"], ["terminalcommand", "bash"], ["shellcommand", "bash"], ["shell", "bash"], ]); const ARG_KEY_ALIASES = new Map([ ["filepath", "path"], ["filename", "path"], ["file", "path"], ["cmd", "command"], ["script", "command"], ["contents", "content"], ["text", "content"], ["oldstring", "oldText"], ["newstring", "newText"], ["old_string", "oldText"], ["new_string", "newText"], ["searchterm", "query"], ["search", "query"], ]); const FALLBACK_MODELS: DiscoveredModel[] = [ { id: "auto", name: "Auto" }, { id: "composer-1.5", name: "Composer 1.5" }, { id: "composer-1", name: "Composer 1" }, { id: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" }, { id: "opus-4.6", name: "Claude 4.6 Opus" }, { id: "sonnet-4.6", name: "Claude 4.6 Sonnet" }, { id: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" }, { id: "opus-4.5", name: "Claude 4.5 Opus" }, { id: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" }, { id: "sonnet-4.5", name: "Claude 4.5 Sonnet" }, { id: "sonnet-4.5-thinking", name: "Claude 4.5 Sonnet (Thinking)" }, { id: "gpt-5.4-high", name: "GPT-5.4 High" }, { id: "gpt-5.4-medium", name: "GPT-5.4" }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, { id: "gpt-5.2", name: "GPT-5.2" }, { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, { id: "gemini-3-pro", name: "Gemini 3 Pro" }, { id: "gemini-3-flash", name: "Gemini 3 Flash" }, { id: "grok", name: "Grok" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, ]; let runtime: RuntimeState | null = null; let runtimePromise: Promise | null = null; function getAuthPaths(): string[] { const home = homedir(); const xdg = process.env.XDG_CONFIG_HOME; const paths: string[] = []; const files = ["cli-config.json", "auth.json"]; for (const file of files) { paths.push(join(home, ".config", "cursor", file)); } if (xdg && xdg !== join(home, ".config")) { for (const file of files) { paths.push(join(xdg, "cursor", file)); } } for (const file of files) { paths.push(join(home, ".cursor", file)); } return paths; } function hasCursorAuth(): boolean { return getAuthPaths().some((path) => existsSync(path)); } function getPiSettingsPath(): string { const home = homedir(); return join(home, ".pi", "agent", "settings.json"); } function getConfiguredDefaultCursorModel(): string | null { try { const settingsPath = getPiSettingsPath(); if (!existsSync(settingsPath)) return null; const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as { defaultProvider?: string; defaultModel?: string; }; if (settings.defaultProvider !== PROVIDER_ID) return null; if (typeof settings.defaultModel !== "string" || settings.defaultModel.length === 0) return null; return settings.defaultModel; } catch { return null; } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function pollForCursorAuth(): Promise { const start = Date.now(); while (Date.now() - start < AUTH_POLL_TIMEOUT_MS) { if (hasCursorAuth()) return true; await sleep(AUTH_POLL_INTERVAL_MS); } return false; } function normalizeToolKey(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, ""); } function resolveToolName(name: string, allowedToolNames: Set): string | null { if (allowedToolNames.has(name)) return name; const normalized = normalizeToolKey(name); for (const allowed of allowedToolNames) { if (normalizeToolKey(allowed) === normalized) return allowed; } const aliased = TOOL_NAME_ALIASES.get(normalized); if (!aliased) return null; for (const allowed of allowedToolNames) { if (normalizeToolKey(allowed) === normalizeToolKey(aliased)) return allowed; } return null; } function normalizeCursorToolName(raw: string): string { if (raw.endsWith("ToolCall")) { const base = raw.slice(0, -"ToolCall".length); return `${base[0]?.toLowerCase() ?? ""}${base.slice(1)}`; } return raw; } function parseNdjson(text: string): StreamJsonEvent[] { return text .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => { try { return JSON.parse(line) as StreamJsonEvent; } catch { return null; } }) .filter((event): event is StreamJsonEvent => event !== null); } function extractText(event: StreamJsonEvent): string { return (event.message?.content ?? []) .filter((part) => part.type === "text" && typeof part.text === "string") .map((part) => part.text ?? "") .join(""); } function extractThinking(event: StreamJsonEvent): string { if (event.type === "thinking") return ""; return (event.message?.content ?? []) .filter((part) => part.type === "thinking" && typeof part.thinking === "string") .map((part) => part.thinking ?? "") .join(""); } function buildPrompt(messages: any[], tools: any[]): string { const lines: string[] = []; if (tools.length > 0) { const toolLines = tools .map((tool) => { const fn = tool?.function ?? tool; return `- ${fn?.name ?? "unknown"}: ${fn?.description ?? ""}\n Parameters: ${JSON.stringify(fn?.parameters ?? {})}`; }) .join("\n"); lines.push( [ "SYSTEM: You are being used as a model backend for pi.", "If you use a tool, do it through your normal Cursor tool calling flow.", "Tool rules:", "- Use write to create new files or fully replace a file.", "- Use edit only for existing files and include an exact oldText/newText replacement.", "- Do not call the same tool repeatedly with the same arguments after a successful result.", "- After a tool result, continue the task instead of re-verifying with identical reads unless something changed.", "Available pi tools:", toolLines, ].join("\n"), ); } for (const message of messages) { const role = typeof message?.role === "string" ? message.role : "user"; if (role === "tool") { const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); lines.push(`TOOL_RESULT (${message.tool_call_id ?? "unknown"}): ${content}`); continue; } if (role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { const toolCalls = message.tool_calls .map((toolCall: any) => { const fn = toolCall?.function ?? {}; return `tool_call(id: ${toolCall?.id ?? "?"}, name: ${fn.name ?? "?"}, args: ${fn.arguments ?? "{}"})`; }) .join("\n"); const text = typeof message.content === "string" ? message.content : ""; lines.push(`ASSISTANT: ${text}${text ? "\n" : ""}${toolCalls}`); continue; } if (typeof message?.content === "string") { lines.push(`${role.toUpperCase()}: ${message.content}`); continue; } if (Array.isArray(message?.content)) { const text = message.content .map((part: any) => (part?.type === "text" && typeof part.text === "string" ? part.text : "")) .filter(Boolean) .join("\n"); if (text) lines.push(`${role.toUpperCase()}: ${text}`); } } if (messages.some((message) => message?.role === "tool")) { lines.push("The above tool calls have already completed. Continue from those results."); } return lines.join("\n\n"); } function parseModels(output: string): DiscoveredModel[] { const seen = new Set(); const models: DiscoveredModel[] = []; for (const line of output.split("\n")) { const match = line.trim().match(/^([a-zA-Z0-9._-]+)\s+-\s+(.+?)(?:\s+\((?:current|default)\))*\s*$/); if (!match) continue; const id = match[1]; if (seen.has(id)) continue; seen.add(id); models.push({ id, name: match[2].trim() }); } return models; } async function execCapture(command: string, args: string[], stdin?: string): Promise { return await new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.on("error", reject); child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("close", (code) => { resolve({ stdout, stderr, code: code ?? 0 }); }); if (stdin) child.stdin.write(stdin); child.stdin.end(); }); } async function discoverModels(): Promise { try { const result = await execCapture(CURSOR_AGENT_CMD, ["models"]); const models = parseModels(result.stdout); return models.length > 0 ? models : FALLBACK_MODELS; } catch { return FALLBACK_MODELS; } } function normalizeArgumentKeys(args: Record): Record { const normalized: Record = { ...args }; for (const [rawKey, value] of Object.entries(args)) { const token = rawKey.toLowerCase().replace(/[^a-z0-9_]/g, ""); const canonical = ARG_KEY_ALIASES.get(token); if (!canonical || canonical === rawKey) continue; if (normalized[canonical] === undefined) { normalized[canonical] = value; } delete normalized[rawKey]; } return normalized; } function transformToolArguments(name: string, args: Record): Record { if (name === "write") { const path = args.path ?? args.filepath ?? args.filename; const content = args.content ?? args.fileText ?? args.text ?? args.new_string; return { ...(path !== undefined ? { path } : {}), ...(content !== undefined ? { content } : {}), }; } if (name === "edit") { const path = args.path ?? args.filepath ?? args.filename; const oldText = args.oldText ?? args.old_string ?? args.oldString ?? ""; const newText = args.newText ?? args.new_string ?? args.newString ?? args.content ?? ""; return { ...(path !== undefined ? { path } : {}), edits: [{ oldText, newText }], }; } if (name === "bash") { const command = args.command ?? args.cmd ?? args.script; return { ...(command !== undefined ? { command } : {}), }; } if (name === "read") { const path = args.path ?? args.filepath ?? args.filename; return { ...(path !== undefined ? { path } : {}), ...(typeof args.offset === "number" ? { offset: args.offset } : {}), ...(typeof args.limit === "number" ? { limit: args.limit } : {}), }; } return args; } type ToolLoopErrorClass = | "validation" | "not_found" | "permission" | "timeout" | "tool_error" | "success" | "unknown"; type ToolSchemaValidationResult = { hasSchema: boolean; ok: boolean; missing: string[]; unexpected: string[]; typeErrors: string[]; repairHint?: string; }; type LoopGuardDecision = { block: boolean; message?: string; silent?: boolean; }; const UNKNOWN_AS_SUCCESS_TOOLS = new Set([ "bash", "read", "write", "edit", "grep", "glob", "ls", "stat", "mkdir", "rm", ]); const EXPLORATION_TOOLS = new Set(["read", "grep", "glob", "ls", "stat", "bash"]); const SEARCH_LIKE_TOOLS = new Set(["web_search", "fetch_content", "fetch_url", "get_search_content"]); const QUERY_STOP_WORDS = new Set([ "a", "an", "and", "are", "about", "best", "details", "for", "from", "how", "i", "in", "info", "information", "is", "it", "mechanic", "of", "on", "page", "pages", "regarding", "show", "the", "their", "them", "they", "to", "what", "work", "works", ]); const MAX_REPEAT = 2; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function renderToolContent(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content .map((part) => { if (typeof part === "string") return part; if (isRecord(part) && typeof part.text === "string") return part.text; return JSON.stringify(part); }) .join(" "); } if (content == null) return ""; return JSON.stringify(content); } function classifyToolResult(content: unknown, isError: boolean | undefined): ToolLoopErrorClass { // Pi sets `isError` on failed tool runs. Do not substring-scan successful payloads: // source files often contain "invalid", "schema", "type error", etc., which would // misclassify reads as "validation" and trip the tool loop guard (see SPA-754-style sessions). if (isError !== true) { return "success"; } const text = renderToolContent(content).toLowerCase(); if ( text.includes("missing required") || text.includes("must not be empty") || text.includes("invalid") || text.includes("schema") || text.includes("type error") ) { return "validation"; } if (text.includes("not found") || text.includes("no such file") || text.includes("file not found") || text.includes("enoent")) { return "not_found"; } if (text.includes("permission denied") || text.includes("forbidden") || text.includes("eacces")) { return "permission"; } if (text.includes("timeout") || text.includes("timed out")) { return "timeout"; } if (isError) { return "tool_error"; } return "success"; } function normalizeErrorClassForTool(toolName: string, errorClass: ToolLoopErrorClass): ToolLoopErrorClass { if (errorClass === "unknown" && UNKNOWN_AS_SUCCESS_TOOLS.has(toolName.toLowerCase())) { return "success"; } return errorClass; } function stableStringify(value: unknown): string { if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(",")}]`; if (value && typeof value === "object") { const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`; } return JSON.stringify(value); } function hashString(value: string): string { let hash = 0x811c9dc5; for (let i = 0; i < value.length; i += 1) { hash ^= value.charCodeAt(i); hash = Math.imul(hash, 0x01000193); } return (hash >>> 0).toString(16).padStart(8, "0"); } function parseToolArguments(toolCall: OpenAiToolCall): Record | null { try { const parsed = JSON.parse(toolCall.function.arguments); return isRecord(parsed) ? parsed : null; } catch { return null; } } function pathFromToolCall(toolCall: OpenAiToolCall): string | null { const args = parseToolArguments(toolCall); if (!args) return null; return typeof args.path === "string" ? args.path : null; } function normalizeQueryIntentText(value: string): string { const tokens = value.toLowerCase().match(/[a-z0-9]+/g) ?? []; const kept: string[] = []; for (const token of tokens) { if (token.length <= 2 && token !== "ai" && token !== "ui") continue; if (QUERY_STOP_WORDS.has(token)) continue; if (kept.includes(token)) continue; kept.push(token); if (kept.length >= 5) break; } return kept.join("|"); } function deriveSearchIntentFingerprint(toolCall: OpenAiToolCall): string | null { const lowered = toolCall.function.name.toLowerCase(); if (!SEARCH_LIKE_TOOLS.has(lowered)) return null; const args = parseToolArguments(toolCall); if (!args) return null; const intents: string[] = []; if (typeof args.query === "string") intents.push(normalizeQueryIntentText(args.query)); if (Array.isArray(args.queries)) { for (const value of args.queries) { if (typeof value === "string") intents.push(normalizeQueryIntentText(value)); } } if (typeof args.url === "string") intents.push(normalizeQueryIntentText(args.url)); if (Array.isArray(args.urls)) { for (const value of args.urls) { if (typeof value === "string") intents.push(normalizeQueryIntentText(value)); } } const normalized = intents.filter((value) => value.length > 0).sort(); if (normalized.length === 0) return null; return `${lowered}|intent:${normalized.join("||")}`; } function buildToolSchemaMap(tools: any[]): Map { const map = new Map(); for (const tool of tools) { const fn = tool?.function ?? tool; if (!fn || typeof fn.name !== "string") continue; if (fn.parameters !== undefined) { map.set(fn.name, fn.parameters); } } return map; } function normalizeToolSpecificArgs(toolName: string, args: Record): Record { const lowered = toolName.toLowerCase(); const normalized: Record = { ...args }; if (lowered === "bash") { if (Array.isArray(normalized.command)) { normalized.command = normalized.command .map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))) .join(" "); } } if (lowered === "write") { if (normalized.content === undefined && typeof normalized.newText === "string") { normalized.content = normalized.newText; } } if (lowered === "edit") { const path = typeof normalized.path === "string" ? normalized.path : undefined; if (!Array.isArray(normalized.edits)) { const oldText = typeof normalized.oldText === "string" ? normalized.oldText : ""; const newText = typeof normalized.newText === "string" ? normalized.newText : typeof normalized.content === "string" ? normalized.content : ""; normalized.edits = [{ oldText, newText }]; } if (path !== undefined) { normalized.path = path; } } return normalized; } function sanitizeArgumentsForSchema(args: Record, schema: unknown): { args: Record; unexpected: string[] } { if (!isRecord(schema) || schema.additionalProperties !== false) { return { args, unexpected: [] }; } const properties = isRecord(schema.properties) ? schema.properties : {}; const allowed = new Set(Object.keys(properties)); const sanitized: Record = {}; const unexpected: string[] = []; for (const [key, value] of Object.entries(args)) { if (allowed.has(key)) sanitized[key] = value; else unexpected.push(key); } return { args: sanitized, unexpected }; } function matchesType(value: unknown, expected: unknown): boolean { if (expected === undefined) return true; if (Array.isArray(expected)) return expected.some((entry) => matchesType(value, entry)); if (typeof expected !== "string") return true; if (expected === "string") return typeof value === "string"; if (expected === "number") return typeof value === "number"; if (expected === "integer") return typeof value === "number" && Number.isInteger(value); if (expected === "boolean") return typeof value === "boolean"; if (expected === "object") return isRecord(value); if (expected === "array") return Array.isArray(value); if (expected === "null") return value === null; return true; } function validateToolArguments(toolName: string, args: Record, schema: unknown, unexpected: string[]): ToolSchemaValidationResult { if (!isRecord(schema)) { return { hasSchema: false, ok: true, missing: [], unexpected: [], typeErrors: [] }; } const properties = isRecord(schema.properties) ? schema.properties : {}; const required = Array.isArray(schema.required) ? schema.required.filter((entry): entry is string => typeof entry === "string") : []; const missing = required.filter((key) => args[key] === undefined); const typeErrors: string[] = []; for (const [key, value] of Object.entries(args)) { const property = properties[key]; if (!isRecord(property)) continue; if (!matchesType(value, property.type)) { typeErrors.push(`${key}: expected ${String(property.type)}`); continue; } if (Array.isArray(property.enum) && !property.enum.some((candidate) => Object.is(candidate, value))) { typeErrors.push(`${key}: expected enum ${JSON.stringify(property.enum)}`); } } const ok = missing.length === 0 && typeErrors.length === 0; let repairHint: string | undefined; if (!ok) { const hints: string[] = []; if (missing.length > 0) hints.push(`missing required: ${missing.join(", ")}`); if (unexpected.length > 0) hints.push(`remove unsupported fields: ${unexpected.join(", ")}`); if (typeErrors.length > 0) hints.push(`fix type errors: ${typeErrors.join("; ")}`); if (toolName.toLowerCase() === "edit") hints.push("edit requires path and edits[{oldText,newText}] for pi"); repairHint = hints.join(" | "); } return { hasSchema: true, ok, missing, unexpected, typeErrors, repairHint, }; } function applyToolSchemaCompat(toolCall: OpenAiToolCall, toolSchemaMap: Map): { toolCall: OpenAiToolCall; normalizedArgs: Record; validation: ToolSchemaValidationResult; } { const parsed = parseToolArguments(toolCall) ?? {}; const keyNormalized = normalizeArgumentKeys(parsed); const specificNormalized = normalizeToolSpecificArgs(toolCall.function.name, keyNormalized); const schema = toolSchemaMap.get(toolCall.function.name); const sanitized = sanitizeArgumentsForSchema(specificNormalized, schema); const validation = validateToolArguments(toolCall.function.name, sanitized.args, schema, sanitized.unexpected); return { toolCall: { ...toolCall, function: { ...toolCall.function, arguments: JSON.stringify(sanitized.args), }, }, normalizedArgs: sanitized.args, validation, }; } function maybeRerouteToolCall( toolCall: OpenAiToolCall, normalizedArgs: Record, allowedToolNames: Set, toolSchemaMap: Map, ): OpenAiToolCall { if (toolCall.function.name !== "edit") return toolCall; if (!allowedToolNames.has("write") || !toolSchemaMap.has("write")) return toolCall; const path = typeof normalizedArgs.path === "string" ? normalizedArgs.path : null; if (!path) return toolCall; const edits = Array.isArray(normalizedArgs.edits) ? normalizedArgs.edits as Array> : []; const firstEdit = edits[0]; const oldText = typeof firstEdit?.oldText === "string" ? firstEdit.oldText : ""; const newText = typeof firstEdit?.newText === "string" ? firstEdit.newText : null; if (newText === null) return toolCall; if (!existsSync(path) || oldText === "") { return { ...toolCall, function: { name: "write", arguments: JSON.stringify({ path, content: newText }), }, }; } return toolCall; } function deriveArgumentShape(rawArguments: string): string { try { const parsed = JSON.parse(rawArguments); const shapeOf = (value: unknown): unknown => { if (Array.isArray(value)) return value.length === 0 ? ["empty"] : [shapeOf(value[0])]; if (isRecord(value)) { const out: Record = {}; for (const key of Object.keys(value).sort()) out[key] = shapeOf(value[key]); return out; } if (value === null) return "null"; return typeof value; }; return JSON.stringify(shapeOf(parsed)); } catch { return "invalid_json"; } } function deriveArgumentValueSignature(rawArguments: string): string { try { const parsed = JSON.parse(rawArguments); return hashString(stableStringify(parsed)); } catch { return `invalid:${hashString(rawArguments)}`; } } function deriveSuccessCoarseFingerprint(toolCall: OpenAiToolCall): string | null { const lowered = toolCall.function.name.toLowerCase(); if (lowered !== "edit" && lowered !== "write") return null; const path = pathFromToolCall(toolCall); if (!path) return null; if (lowered === "edit") { const args = parseToolArguments(toolCall); if (!args) return null; const edits = Array.isArray(args.edits) ? args.edits as Array> : []; const oldText = typeof edits[0]?.oldText === "string" ? edits[0].oldText : null; if (oldText !== "") return null; } return `${toolCall.function.name}|path:${hashString(path)}|success`; } function collectPriorAssistantToolCalls(messages: any[]): OpenAiToolCall[] { const calls: OpenAiToolCall[] = []; for (const message of messages) { if (message?.role !== "assistant" || !Array.isArray(message.tool_calls)) continue; for (const call of message.tool_calls) { if (!isRecord(call)) continue; const fn = isRecord(call.function) ? call.function : null; if (!fn || typeof fn.name !== "string") continue; calls.push({ id: typeof call.id === "string" ? call.id : "", type: "function", function: { name: fn.name, arguments: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {}), }, }); } } return calls; } function buildResultClassByCallId(messages: any[]): { byCallId: Map; latest: ToolLoopErrorClass | null } { const byCallId = new Map(); let latest: ToolLoopErrorClass | null = null; for (const message of messages) { if (message?.role !== "tool") continue; const errorClass = classifyToolResult(message.content, message.isError); latest = errorClass; if (typeof message.tool_call_id === "string" && message.tool_call_id.length > 0) { byCallId.set(message.tool_call_id, errorClass); } } return { byCallId, latest }; } function evaluateToolLoopGuard(messages: any[], toolCall: OpenAiToolCall): LoopGuardDecision { const priorCalls = collectPriorAssistantToolCalls(messages); const { byCallId, latest } = buildResultClassByCallId(messages); const matchingPrior = priorCalls.filter((prior) => prior.function.name === toolCall.function.name); const errorClassRaw = matchingPrior.length > 0 ? (byCallId.get(matchingPrior[matchingPrior.length - 1].id) ?? latest ?? "unknown") : (latest ?? "unknown"); const errorClass = normalizeErrorClassForTool(toolCall.function.name, errorClassRaw); const strictFingerprint = `${toolCall.function.name}|${deriveArgumentShape(toolCall.function.arguments)}|${errorClass}`; const coarseFingerprint = `${toolCall.function.name}|${errorClass}`; let strictCount = 0; let coarseCount = 0; for (const prior of priorCalls) { const priorErrorRaw = byCallId.get(prior.id) ?? latest ?? "unknown"; const priorError = normalizeErrorClassForTool(prior.function.name, priorErrorRaw); const priorStrict = `${prior.function.name}|${deriveArgumentShape(prior.function.arguments)}|${priorError}`; if (priorStrict === strictFingerprint) strictCount += 1; if (`${prior.function.name}|${priorError}` === coarseFingerprint) coarseCount += 1; } if (errorClass === "success") { const valueFp = `${toolCall.function.name}|values:${deriveArgumentValueSignature(toolCall.function.arguments)}|success`; let successCount = 0; for (const prior of priorCalls) { const priorErrorRaw = byCallId.get(prior.id) ?? latest ?? "unknown"; const priorError = normalizeErrorClassForTool(prior.function.name, priorErrorRaw); if (priorError !== "success") continue; const priorFp = `${prior.function.name}|values:${deriveArgumentValueSignature(prior.function.arguments)}|success`; if (priorFp === valueFp) successCount += 1; } const successLimit = EXPLORATION_TOOLS.has(toolCall.function.name.toLowerCase()) ? MAX_REPEAT * 5 : MAX_REPEAT; if (successCount >= successLimit) { return { block: true, silent: true }; } const coarseSuccess = deriveSuccessCoarseFingerprint(toolCall); if (coarseSuccess) { let coarseSuccessCount = 0; for (const prior of priorCalls) { const priorErrorRaw = byCallId.get(prior.id) ?? latest ?? "unknown"; const priorError = normalizeErrorClassForTool(prior.function.name, priorErrorRaw); if (priorError !== "success") continue; if (deriveSuccessCoarseFingerprint(prior) === coarseSuccess) coarseSuccessCount += 1; } if (coarseSuccessCount >= successLimit) return { block: true, silent: true }; } const searchIntent = deriveSearchIntentFingerprint(toolCall); if (searchIntent) { let searchIntentCount = 0; for (const prior of priorCalls) { const priorErrorRaw = byCallId.get(prior.id) ?? latest ?? "unknown"; const priorError = normalizeErrorClassForTool(prior.function.name, priorErrorRaw); if (priorError !== "success") continue; if (deriveSearchIntentFingerprint(prior) === searchIntent) searchIntentCount += 1; } if (searchIntentCount >= 2) { return { block: true, message: `You've already used ${toolCall.function.name} several times for the same query intent. Stop searching and answer using the results you already have.`, }; } } return { block: false }; } const strictLimit = EXPLORATION_TOOLS.has(toolCall.function.name.toLowerCase()) ? MAX_REPEAT * 5 : MAX_REPEAT; if (strictCount >= strictLimit) { return { block: true, message: `Tool loop guard stopped repeated ${errorClass} calls to ${toolCall.function.name}. Adjust arguments and continue.`, }; } if (!EXPLORATION_TOOLS.has(toolCall.function.name.toLowerCase())) { const coarseLimit = MAX_REPEAT * 3; if (coarseCount >= coarseLimit) { return { block: true, message: `Tool loop guard stopped repeated failing calls to ${toolCall.function.name}. Try a different approach.`, }; } } return { block: false }; } function createSchemaValidationMessage(toolCall: OpenAiToolCall, validation: ToolSchemaValidationResult): string { const reasons: string[] = []; if (validation.missing.length > 0) reasons.push(`missing required: ${validation.missing.join(", ")}`); if (validation.unexpected.length > 0) reasons.push(`unsupported fields: ${validation.unexpected.join(", ")}`); if (validation.typeErrors.length > 0) reasons.push(`type errors: ${validation.typeErrors.join("; ")}`); const reasonText = reasons.length > 0 ? reasons.join(" | ") : "arguments did not match schema"; return `Invalid arguments for ${toolCall.function.name}: ${reasonText}. ${validation.repairHint ?? "Fix args and retry."}`.trim(); } function extractInterceptedToolCall( events: StreamJsonEvent[], allowedToolNames: Set, messages: any[], toolSchemaMap: Map, ): { toolCall: OpenAiToolCall | null; guard?: LoopGuardDecision } { for (const event of events) { if (event.type !== "tool_call" || !event.tool_call) continue; const [rawName, payload] = Object.entries(event.tool_call)[0] ?? []; if (!rawName || !payload) continue; const normalizedName = normalizeCursorToolName(rawName); const toolName = resolveToolName(normalizedName, allowedToolNames); if (!toolName) continue; const rawArgs = (payload.args ?? {}) as Record; const args = normalizeArgumentKeys(rawArgs); const finalArgs = transformToolArguments(toolName, args); let toolCall: OpenAiToolCall = { id: event.call_id ?? `call_${Date.now()}`, type: "function", function: { name: toolName, arguments: JSON.stringify(finalArgs), }, }; const compat = applyToolSchemaCompat(toolCall, toolSchemaMap); toolCall = compat.toolCall; toolCall = maybeRerouteToolCall(toolCall, compat.normalizedArgs, allowedToolNames, toolSchemaMap); if (compat.validation.hasSchema && !compat.validation.ok && toolCall.function.name === compat.toolCall.function.name) { return { toolCall: null, guard: { block: true, message: createSchemaValidationMessage(toolCall, compat.validation), }, }; } const guard = evaluateToolLoopGuard(messages, toolCall); if (guard.block) { return { toolCall: null, guard }; } return { toolCall, guard }; } return { toolCall: null }; } function createChatCompletionResponse(model: string, content: string, reasoningContent?: string) { return { id: `cursor-acp-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, choices: [ { index: 0, message: { role: "assistant", content, ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), }, finish_reason: "stop", }, ], }; } function createToolCallCompletionResponse(model: string, toolCall: OpenAiToolCall) { return { id: `cursor-acp-${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, choices: [ { index: 0, message: { role: "assistant", content: null, tool_calls: [toolCall], }, finish_reason: "tool_calls", }, ], }; } function createChatCompletionChunk( model: string, delta: { content?: string; reasoning_content?: string; tool_calls?: OpenAiToolCall[] }, finishReason: "stop" | "tool_calls" | null = null, meta?: { id: string; created: number }, ) { return { id: meta?.id ?? `cursor-acp-${Date.now()}`, object: "chat.completion.chunk", created: meta?.created ?? Math.floor(Date.now() / 1000), model, choices: [ { index: 0, delta, finish_reason: finishReason, }, ], }; } function sendJson(res: ServerResponse, status: number, body: unknown) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); } async function readJsonBody(req: IncomingMessage): Promise { let body = ""; for await (const chunk of req) { body += chunk.toString(); } return body ? JSON.parse(body) : {}; } function collectAssistantResponse(events: StreamJsonEvent[]): { assistantText: string; thinkingText: string; assistantChunks: string[]; thinkingChunks: string[]; } { let assistantText = ""; let thinkingText = ""; let sawAssistantPartials = false; let sawThinkingPartials = false; const assistantChunks: string[] = []; const thinkingChunks: string[] = []; for (const event of events) { if (event.type !== "assistant") continue; const text = extractText(event); const thinking = extractThinking(event); const isPartial = typeof event.timestamp_ms === "number"; if (text) { if (isPartial) { sawAssistantPartials = true; // Partials are cumulative — only take the delta that extends current text. if (text.startsWith(assistantText) && text.length > assistantText.length) { const delta = text.slice(assistantText.length); assistantChunks.push(delta); assistantText = text; } else if (!assistantText) { assistantChunks.push(text); assistantText = text; } // If text doesn't extend assistantText, skip (reset/duplicate). } else if (!sawAssistantPartials) { assistantText = text; } } if (thinking) { if (isPartial) { sawThinkingPartials = true; if (thinking.startsWith(thinkingText) && thinking.length > thinkingText.length) { const delta = thinking.slice(thinkingText.length); thinkingChunks.push(delta); thinkingText = thinking; } else if (!thinkingText) { thinkingChunks.push(thinking); thinkingText = thinking; } } else if (!sawThinkingPartials) { thinkingText = thinking; } } } if (!sawAssistantPartials && assistantText) assistantChunks.push(assistantText); if (!sawThinkingPartials && thinkingText) thinkingChunks.push(thinkingText); return { assistantText, thinkingText, assistantChunks, thinkingChunks }; } function sendSse(res: ServerResponse, payload: unknown) { res.write(`data: ${JSON.stringify(payload)}\n\n`); } function endSse(res: ServerResponse) { res.write("data: [DONE]\n\n"); res.end(); } function beginSse(res: ServerResponse) { res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }); } function getNovelStreamingDelta(previous: string, next: string): { delta: string; snapshot: string } { if (!next) return { delta: "", snapshot: previous }; if (!previous) return { delta: next, snapshot: next }; if (next === previous) return { delta: "", snapshot: previous }; if (next.startsWith(previous)) { return { delta: next.slice(previous.length), snapshot: next, }; } // cursor-agent sometimes "resets" mid-stream (e.g. new message block). // Do NOT emit or update snapshot — treat as stale/duplicate to prevent re-sending. return { delta: "", snapshot: previous }; } async function handleStreamingChatRequest( res: ServerResponse, workspace: string, model: string, prompt: string, allowedToolNames: Set, messages: any[], toolSchemaMap: Map, ) { beginSse(res); const responseMeta = { id: `cursor-acp-${Date.now()}`, created: Math.floor(Date.now() / 1000), }; const args = [ "--print", "--output-format", "stream-json", "--stream-partial-output", "--workspace", workspace, "--model", model, ]; if (CURSOR_FORCE) args.push("--force"); const child = spawn(CURSOR_AGENT_CMD, args, { stdio: ["pipe", "pipe", "pipe"] }); let stdoutBuffer = ""; let stdoutText = ""; let stderrText = ""; let streamedAny = false; let finished = false; let lastAssistantSnapshot = ""; let lastThinkingSnapshot = ""; const finishStop = () => { if (finished) return; finished = true; sendSse(res, createChatCompletionChunk(model, {}, "stop", responseMeta)); endSse(res); }; const finishToolCall = (toolCall: OpenAiToolCall) => { if (finished) return; finished = true; sendSse(res, createChatCompletionChunk(model, { tool_calls: [toolCall] }, null, responseMeta)); sendSse(res, createChatCompletionChunk(model, {}, "tool_calls", responseMeta)); endSse(res); child.kill(); }; const processEvent = (event: StreamJsonEvent) => { if (finished) return; if (event.type === "tool_call") { const interception = extractInterceptedToolCall([event], allowedToolNames, messages, toolSchemaMap); if (interception.toolCall) { finishToolCall(interception.toolCall); return; } if (interception.guard?.block && !interception.guard.silent && interception.guard.message) { streamedAny = true; sendSse(res, createChatCompletionChunk(model, { content: interception.guard.message }, null, responseMeta)); } return; } if (event.type !== "assistant") return; const text = extractText(event); const thinking = extractThinking(event); const isPartial = typeof event.timestamp_ms === "number"; if (!isPartial) return; if (thinking) { const { delta, snapshot } = getNovelStreamingDelta(lastThinkingSnapshot, thinking); lastThinkingSnapshot = snapshot; if (delta) { streamedAny = true; sendSse(res, createChatCompletionChunk(model, { reasoning_content: delta }, null, responseMeta)); } } if (text) { const { delta, snapshot } = getNovelStreamingDelta(lastAssistantSnapshot, text); lastAssistantSnapshot = snapshot; if (delta) { streamedAny = true; sendSse(res, createChatCompletionChunk(model, { content: delta }, null, responseMeta)); } } }; child.on("error", (error) => { if (finished) return; finished = true; sendSse( res, createChatCompletionChunk( model, { content: `${CURSOR_AGENT_CMD} failed to start: ${error.message}` }, "stop", responseMeta, ), ); endSse(res); }); child.stdout.on("data", (chunk) => { const text = chunk.toString(); stdoutText += text; stdoutBuffer += text; while (true) { const newlineIndex = stdoutBuffer.indexOf("\n"); if (newlineIndex === -1) break; const line = stdoutBuffer.slice(0, newlineIndex); stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1); const trimmed = line.trim(); if (!trimmed) continue; try { processEvent(JSON.parse(trimmed) as StreamJsonEvent); } catch { // ignore malformed lines } } }); child.stderr.on("data", (chunk) => { stderrText += chunk.toString(); }); child.stdin.write(prompt); child.stdin.end(); child.on("close", () => { if (finished) return; if (!streamedAny) { const events = parseNdjson(stdoutText); const response = collectAssistantResponse(events); const content = response.assistantText || stderrText.trim() || stdoutText.trim() || "No response"; for (const chunk of response.thinkingChunks) { sendSse(res, createChatCompletionChunk(model, { reasoning_content: chunk }, null, responseMeta)); } for (const chunk of response.assistantChunks) { sendSse(res, createChatCompletionChunk(model, { content: chunk }, null, responseMeta)); } if (response.assistantChunks.length === 0 && response.thinkingChunks.length === 0 && content) { sendSse(res, createChatCompletionChunk(model, { content }, null, responseMeta)); } } finishStop(); }); } async function handleChatRequest(res: ServerResponse, workspace: string, body: any) { const model = typeof body?.model === "string" ? body.model.replace(`${PROVIDER_ID}/`, "") : "auto"; const messages = Array.isArray(body?.messages) ? body.messages : []; const tools = Array.isArray(body?.tools) ? body.tools : []; const stream = body?.stream === true; const prompt = buildPrompt(messages, tools); const allowedToolNames = new Set( tools .map((tool: any) => tool?.function?.name ?? tool?.name) .filter((name: unknown): name is string => typeof name === "string" && name.length > 0), ); const toolSchemaMap = buildToolSchemaMap(tools); if (stream) { await handleStreamingChatRequest(res, workspace, model, prompt, allowedToolNames, messages, toolSchemaMap); return; } let result: ExecResult; try { const args = [ "--print", "--output-format", "stream-json", "--stream-partial-output", "--workspace", workspace, "--model", model, ]; if (CURSOR_FORCE) args.push("--force"); result = await execCapture(CURSOR_AGENT_CMD, args, prompt); } catch (error) { const message = error instanceof Error ? error.message : String(error); sendJson(res, 200, createChatCompletionResponse(model, `${CURSOR_AGENT_CMD} failed to start: ${message}`)); return; } const events = parseNdjson(result.stdout); const interception = extractInterceptedToolCall(events, allowedToolNames, messages, toolSchemaMap); if (interception.toolCall) { sendJson(res, 200, createToolCallCompletionResponse(model, interception.toolCall)); return; } if (interception.guard?.block && !interception.guard.silent && interception.guard.message) { sendJson(res, 200, createChatCompletionResponse(model, interception.guard.message)); return; } const response = collectAssistantResponse(events); const content = response.assistantText || result.stderr.trim() || result.stdout.trim() || "No response"; sendJson(res, 200, createChatCompletionResponse(model, content, response.thinkingText || undefined)); } async function startRuntime(workspace: string): Promise { let models = await discoverModels(); const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? HOST}`); if (url.pathname === "/health") { sendJson(res, 200, { ok: true, workspace }); return; } if (url.pathname === "/v1/models" || url.pathname === "/models") { models = await discoverModels(); sendJson(res, 200, { object: "list", data: models.map((model) => ({ id: model.id, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "cursor", })), }); return; } if (url.pathname !== "/v1/chat/completions" && url.pathname !== "/chat/completions") { sendJson(res, 404, { error: `Unsupported path: ${url.pathname}` }); return; } const body = await readJsonBody(req); await handleChatRequest(res, workspace, body); } catch (error) { const message = error instanceof Error ? error.message : String(error); sendJson(res, 500, { error: message }); } }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, HOST, () => resolve()); }); const address = server.address(); if (!address || typeof address === "string") { server.close(); throw new Error("Failed to determine cursor-acp proxy address"); } return { server, baseUrl: `http://${HOST}:${address.port}/v1`, workspace, models, }; } async function ensureRuntime(workspace: string): Promise { if (runtime && runtime.workspace === workspace) return runtime; if (runtimePromise) return await runtimePromise; runtimePromise = (async () => { if (runtime) { await new Promise((resolve) => runtime?.server.close(() => resolve())); runtime = null; } const started = await startRuntime(workspace); runtime = started; runtimePromise = null; return started; })().catch((error) => { runtimePromise = null; throw error; }); return await runtimePromise; } async function refreshProvider(pi: ExtensionAPI, workspace: string) { const state = await ensureRuntime(workspace); pi.registerProvider(PROVIDER_ID, { baseUrl: state.baseUrl, apiKey: "cursor-auth", api: "openai-completions", models: state.models.map((model) => ({ id: model.id, name: model.name, reasoning: true, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200_000, maxTokens: 32_000, compat: { supportsDeveloperRole: false, supportsReasoningEffort: false, supportsUsageInStreaming: false, }, })), oauth: { name: PROVIDER_NAME, login: loginCursor, refreshToken: refreshCursorCredentials, getApiKey(credentials) { return credentials.access; }, }, }); } async function loginCursor(callbacks: OAuthLoginCallbacks): Promise { return await new Promise((resolve, reject) => { const child = spawn(CURSOR_AGENT_CMD, ["login"], { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; let settled = false; const finish = (fn: () => void) => { if (settled) return; settled = true; fn(); }; const extractUrl = () => { const clean = `${stdout}${stderr}`.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s/g, ""); const match = clean.match(/https:\/\/cursor\.com\/loginDeepControl[^\s]*/); return match?.[0] ?? null; }; child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", (error) => finish(() => reject(error))); const pollForUrl = async () => { const start = Date.now(); while (Date.now() - start < LOGIN_URL_TIMEOUT_MS) { const url = extractUrl(); if (url) { callbacks.onAuth({ url }); return; } await sleep(100); } finish(() => reject(new Error(stderr.trim() || "Failed to get Cursor login URL"))); }; void pollForUrl(); child.on("close", async (code) => { if (settled) return; if (code !== 0) { finish(() => reject(new Error(stderr.trim() || `${CURSOR_AGENT_CMD} login exited with code ${code}`))); return; } const ok = await pollForCursorAuth(); if (!ok) { finish(() => reject(new Error("Cursor login did not complete in time"))); return; } finish(() => resolve({ refresh: "cursor-auth", access: "cursor-auth", expires: Date.now() + 365 * 24 * 60 * 60 * 1000, }), ); }); }); } async function refreshCursorCredentials(credentials: OAuthCredentials): Promise { if (!hasCursorAuth()) { throw new Error(`Cursor is not logged in. Run /login cursor-acp or ${CURSOR_AGENT_CMD} login.`); } return { refresh: credentials.refresh || "cursor-auth", access: "cursor-auth", expires: Date.now() + 365 * 24 * 60 * 60 * 1000, }; } export default function (pi: ExtensionAPI) { pi.on("session_start", async (_event, ctx) => { try { await refreshProvider(pi, ctx.cwd); const configuredModelId = getConfiguredDefaultCursorModel(); if (configuredModelId) { const configuredModel = ctx.modelRegistry.find(PROVIDER_ID, configuredModelId); if (configuredModel) { await pi.setModel(configuredModel); } } ctx.ui.notify(`cursor-acp ready (${runtime?.models.length ?? 0} models)`, "info"); } catch (error) { const message = error instanceof Error ? error.message : String(error); ctx.ui.notify(`cursor-acp unavailable: ${message}`, "warning"); } }); pi.on("session_shutdown", async () => { if (!runtime) return; await new Promise((resolve) => runtime?.server.close(() => resolve())); runtime = null; runtimePromise = null; }); pi.registerCommand("cursor-acp-status", { description: "Show Cursor ACP provider status", handler: async (_args, ctx) => { const version = await execCapture(CURSOR_AGENT_CMD, ["--version"]).catch(() => null); const models = await discoverModels(); const status = [ `provider: ${PROVIDER_ID}`, `cli (${CURSOR_AGENT_CMD}): ${version?.stdout.trim() || "not installed"}`, `logged in: ${hasCursorAuth() ? "yes" : "no"}`, `proxy: ${runtime?.baseUrl ?? "not started"}`, `models: ${models.length}`, ].join("\n"); ctx.ui.notify(status, "info"); }, }); pi.registerCommand("cursor-acp-sync", { description: "Rediscover Cursor models and refresh the pi provider", handler: async (_args, ctx) => { await refreshProvider(pi, ctx.cwd); ctx.ui.notify(`cursor-acp synced (${runtime?.models.length ?? 0} models)`, "info"); }, }); }