Files
dotfiles/pi/files/agent/extensions/cursor-acp.ts
T
2026-04-16 11:55:57 +01:00

1571 lines
47 KiB
TypeScript

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<typeof createServer>;
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<string, { args?: Record<string, unknown>; 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<string, string>([
["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<string, string>([
["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<RuntimeState> | 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function pollForCursorAuth(): Promise<boolean> {
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>): 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<string>();
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<ExecResult> {
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<DiscoveredModel[]> {
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<string, unknown>): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...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<string, unknown>): Record<string, unknown> {
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<string, unknown> {
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<string, unknown>).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<string, unknown> | 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<string, unknown> {
const map = new Map<string, unknown>();
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<string, unknown>): Record<string, unknown> {
const lowered = toolName.toLowerCase();
const normalized: Record<string, unknown> = { ...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<string, unknown>, schema: unknown): { args: Record<string, unknown>; 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<string, unknown> = {};
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<string, unknown>, 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<string, unknown>): {
toolCall: OpenAiToolCall;
normalizedArgs: Record<string, unknown>;
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<string, unknown>,
allowedToolNames: Set<string>,
toolSchemaMap: Map<string, unknown>,
): 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<Record<string, unknown>>
: [];
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<string, unknown> = {};
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<Record<string, unknown>> : [];
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<string, ToolLoopErrorClass>; latest: ToolLoopErrorClass | null } {
const byCallId = new Map<string, ToolLoopErrorClass>();
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<string>,
messages: any[],
toolSchemaMap: Map<string, unknown>,
): { 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<string, unknown>;
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<any> {
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<string>,
messages: any[],
toolSchemaMap: Map<string, unknown>,
) {
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<string>(
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<RuntimeState> {
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<void>((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<RuntimeState> {
if (runtime && runtime.workspace === workspace) return runtime;
if (runtimePromise) return await runtimePromise;
runtimePromise = (async () => {
if (runtime) {
await new Promise<void>((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<OAuthCredentials> {
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<OAuthCredentials> {
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<void>((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");
},
});
}