diff --git a/pi/files/agent/extensions/cursor-acp.ts b/pi/files/agent/extensions/cursor-acp.ts deleted file mode 100644 index 7b805cf..0000000 --- a/pi/files/agent/extensions/cursor-acp.ts +++ /dev/null @@ -1,1615 +0,0 @@ -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"); - }, - }); -}