From 534ec8b99f92cbe208eb0cb8fe904208d6688714 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Thu, 16 Apr 2026 09:57:23 +0100 Subject: [PATCH] cursor extension --- fish/files/config.fish | 13 +- pi/files.linux/agent/settings.json | 6 +- pi/files/agent/extensions/cursor-acp.ts | 1570 +++++++++++++++++++++++ 3 files changed, 1584 insertions(+), 5 deletions(-) create mode 100644 pi/files/agent/extensions/cursor-acp.ts diff --git a/fish/files/config.fish b/fish/files/config.fish index 21d47d5..79a9dc1 100644 --- a/fish/files/config.fish +++ b/fish/files/config.fish @@ -102,9 +102,18 @@ status is-interactive; and begin end -# Add user local bin to PATH +# PATH ordering: prefer Nix/system binaries over self-installed shims in ~/.local/bin +if test (uname) = Linux + fish_add_path -m /run/current-system/sw/bin +end + +# Add user local bin to PATH, but keep it after system paths on Linux if test -d "$HOME/.local/bin" - fish_add_path "$HOME/.local/bin" + if test (uname) = Linux + fish_add_path -a -m "$HOME/.local/bin" + else + fish_add_path "$HOME/.local/bin" + end end # pnpm diff --git a/pi/files.linux/agent/settings.json b/pi/files.linux/agent/settings.json index 60ced90..8f24fad 100644 --- a/pi/files.linux/agent/settings.json +++ b/pi/files.linux/agent/settings.json @@ -1,7 +1,7 @@ { - "lastChangelogVersion": "0.67.1", - "defaultProvider": "openai-codex", - "defaultModel": "gpt-5.4", + "lastChangelogVersion": "0.67.3", + "defaultProvider": "cursor-acp", + "defaultModel": "auto", "defaultThinkingLevel": "medium", "theme": "matugen", "lsp": { diff --git a/pi/files/agent/extensions/cursor-acp.ts b/pi/files/agent/extensions/cursor-acp.ts new file mode 100644 index 0000000..d4c98e2 --- /dev/null +++ b/pi/files/agent/extensions/cursor-acp.ts @@ -0,0 +1,1570 @@ +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"; + +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", ["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 { + 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; + assistantText += text; + assistantChunks.push(text); + } else if (!sawAssistantPartials) { + assistantText = text; + } + } + + if (thinking) { + if (isPartial) { + sawThinkingPartials = true; + thinkingText += thinking; + thinkingChunks.push(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", + }); +} + +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", args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdoutBuffer = ""; + let stdoutText = ""; + let stderrText = ""; + let streamedAny = false; + let finished = false; + let lastAssistantDelta = ""; + let lastThinkingDelta = ""; + + 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) { + if (thinking !== lastThinkingDelta) { + streamedAny = true; + lastThinkingDelta = thinking; + sendSse(res, createChatCompletionChunk(model, { reasoning_content: thinking }, null, responseMeta)); + } + } else { + lastThinkingDelta = ""; + } + if (text) { + if (text !== lastAssistantDelta) { + streamedAny = true; + lastAssistantDelta = text; + sendSse(res, createChatCompletionChunk(model, { content: text }, null, responseMeta)); + } + } else { + lastAssistantDelta = ""; + } + }; + + child.on("error", (error) => { + if (finished) return; + finished = true; + sendSse(res, createChatCompletionChunk(model, { content: `cursor-agent 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", args, prompt); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(res, 200, createChatCompletionResponse(model, `cursor-agent 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", ["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 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 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", ["--version"]).catch(() => null); + const models = await discoverModels(); + const status = [ + `provider: ${PROVIDER_ID}`, + `cursor-agent: ${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"); + }, + }); +}