1616 lines
49 KiB
TypeScript
1616 lines
49 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";
|
|
/** Same resolution as community providers: https://github.com/netandreus/pi-cursor-provider */
|
|
const CURSOR_AGENT_CMD =
|
|
process.env.PI_CURSOR_AGENT_PATH ?? process.env.CURSOR_AGENT_PATH ?? process.env.AGENT_PATH ?? "cursor-agent";
|
|
|
|
const TOOL_NAME_ALIASES = new Map<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_CMD, ["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 {
|
|
// Pi sets `isError` on failed tool runs. Do not substring-scan successful payloads:
|
|
// source files often contain "invalid", "schema", "type error", etc., which would
|
|
// misclassify reads as "validation" and trip the tool loop guard (see SPA-754-style sessions).
|
|
if (isError !== true) {
|
|
return "success";
|
|
}
|
|
|
|
const text = renderToolContent(content).toLowerCase();
|
|
if (
|
|
text.includes("missing required")
|
|
|| text.includes("must not be empty")
|
|
|| text.includes("invalid")
|
|
|| text.includes("schema")
|
|
|| text.includes("type error")
|
|
) {
|
|
return "validation";
|
|
}
|
|
if (text.includes("not found") || text.includes("no such file") || text.includes("file not found") || text.includes("enoent")) {
|
|
return "not_found";
|
|
}
|
|
if (text.includes("permission denied") || text.includes("forbidden") || text.includes("eacces")) {
|
|
return "permission";
|
|
}
|
|
if (text.includes("timeout") || text.includes("timed out")) {
|
|
return "timeout";
|
|
}
|
|
if (isError) {
|
|
return "tool_error";
|
|
}
|
|
return "success";
|
|
}
|
|
|
|
function normalizeErrorClassForTool(toolName: string, errorClass: ToolLoopErrorClass): ToolLoopErrorClass {
|
|
if (errorClass === "unknown" && UNKNOWN_AS_SUCCESS_TOOLS.has(toolName.toLowerCase())) {
|
|
return "success";
|
|
}
|
|
return errorClass;
|
|
}
|
|
|
|
function stableStringify(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
const entries = Object.entries(value as Record<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;
|
|
// Partials are cumulative — only take the delta that extends current text.
|
|
if (text.startsWith(assistantText) && text.length > assistantText.length) {
|
|
const delta = text.slice(assistantText.length);
|
|
assistantChunks.push(delta);
|
|
assistantText = text;
|
|
} else if (!assistantText) {
|
|
assistantChunks.push(text);
|
|
assistantText = text;
|
|
}
|
|
// If text doesn't extend assistantText, skip (reset/duplicate).
|
|
} else if (!sawAssistantPartials) {
|
|
assistantText = text;
|
|
}
|
|
}
|
|
|
|
if (thinking) {
|
|
if (isPartial) {
|
|
sawThinkingPartials = true;
|
|
if (thinking.startsWith(thinkingText) && thinking.length > thinkingText.length) {
|
|
const delta = thinking.slice(thinkingText.length);
|
|
thinkingChunks.push(delta);
|
|
thinkingText = thinking;
|
|
} else if (!thinkingText) {
|
|
thinkingChunks.push(thinking);
|
|
thinkingText = thinking;
|
|
}
|
|
} else if (!sawThinkingPartials) {
|
|
thinkingText = thinking;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sawAssistantPartials && assistantText) assistantChunks.push(assistantText);
|
|
if (!sawThinkingPartials && thinkingText) thinkingChunks.push(thinkingText);
|
|
|
|
return { assistantText, thinkingText, assistantChunks, thinkingChunks };
|
|
}
|
|
|
|
function sendSse(res: ServerResponse, payload: unknown) {
|
|
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
}
|
|
|
|
function endSse(res: ServerResponse) {
|
|
res.write("data: [DONE]\n\n");
|
|
res.end();
|
|
}
|
|
|
|
function beginSse(res: ServerResponse) {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
});
|
|
}
|
|
|
|
function getNovelStreamingDelta(previous: string, next: string): { delta: string; snapshot: string } {
|
|
if (!next) return { delta: "", snapshot: previous };
|
|
if (!previous) return { delta: next, snapshot: next };
|
|
if (next === previous) return { delta: "", snapshot: previous };
|
|
if (next.startsWith(previous)) {
|
|
return {
|
|
delta: next.slice(previous.length),
|
|
snapshot: next,
|
|
};
|
|
}
|
|
// cursor-agent sometimes "resets" mid-stream (e.g. new message block).
|
|
// Do NOT emit or update snapshot — treat as stale/duplicate to prevent re-sending.
|
|
return { delta: "", snapshot: previous };
|
|
}
|
|
|
|
async function handleStreamingChatRequest(
|
|
res: ServerResponse,
|
|
workspace: string,
|
|
model: string,
|
|
prompt: string,
|
|
allowedToolNames: Set<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_CMD, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
let stdoutBuffer = "";
|
|
let stdoutText = "";
|
|
let stderrText = "";
|
|
let streamedAny = false;
|
|
let finished = false;
|
|
let lastAssistantSnapshot = "";
|
|
let lastThinkingSnapshot = "";
|
|
|
|
const finishStop = () => {
|
|
if (finished) return;
|
|
finished = true;
|
|
sendSse(res, createChatCompletionChunk(model, {}, "stop", responseMeta));
|
|
endSse(res);
|
|
};
|
|
|
|
const finishToolCall = (toolCall: OpenAiToolCall) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
sendSse(res, createChatCompletionChunk(model, { tool_calls: [toolCall] }, null, responseMeta));
|
|
sendSse(res, createChatCompletionChunk(model, {}, "tool_calls", responseMeta));
|
|
endSse(res);
|
|
child.kill();
|
|
};
|
|
|
|
const processEvent = (event: StreamJsonEvent) => {
|
|
if (finished) return;
|
|
if (event.type === "tool_call") {
|
|
const interception = extractInterceptedToolCall([event], allowedToolNames, messages, toolSchemaMap);
|
|
if (interception.toolCall) {
|
|
finishToolCall(interception.toolCall);
|
|
return;
|
|
}
|
|
if (interception.guard?.block && !interception.guard.silent && interception.guard.message) {
|
|
streamedAny = true;
|
|
sendSse(res, createChatCompletionChunk(model, { content: interception.guard.message }, null, responseMeta));
|
|
}
|
|
return;
|
|
}
|
|
if (event.type !== "assistant") return;
|
|
|
|
const text = extractText(event);
|
|
const thinking = extractThinking(event);
|
|
const isPartial = typeof event.timestamp_ms === "number";
|
|
if (!isPartial) return;
|
|
|
|
if (thinking) {
|
|
const { delta, snapshot } = getNovelStreamingDelta(lastThinkingSnapshot, thinking);
|
|
lastThinkingSnapshot = snapshot;
|
|
if (delta) {
|
|
streamedAny = true;
|
|
sendSse(res, createChatCompletionChunk(model, { reasoning_content: delta }, null, responseMeta));
|
|
}
|
|
}
|
|
if (text) {
|
|
const { delta, snapshot } = getNovelStreamingDelta(lastAssistantSnapshot, text);
|
|
lastAssistantSnapshot = snapshot;
|
|
if (delta) {
|
|
streamedAny = true;
|
|
sendSse(res, createChatCompletionChunk(model, { content: delta }, null, responseMeta));
|
|
}
|
|
}
|
|
};
|
|
|
|
child.on("error", (error) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
sendSse(
|
|
res,
|
|
createChatCompletionChunk(
|
|
model,
|
|
{ content: `${CURSOR_AGENT_CMD} failed to start: ${error.message}` },
|
|
"stop",
|
|
responseMeta,
|
|
),
|
|
);
|
|
endSse(res);
|
|
});
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
const text = chunk.toString();
|
|
stdoutText += text;
|
|
stdoutBuffer += text;
|
|
while (true) {
|
|
const newlineIndex = stdoutBuffer.indexOf("\n");
|
|
if (newlineIndex === -1) break;
|
|
const line = stdoutBuffer.slice(0, newlineIndex);
|
|
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
processEvent(JSON.parse(trimmed) as StreamJsonEvent);
|
|
} catch {
|
|
// ignore malformed lines
|
|
}
|
|
}
|
|
});
|
|
|
|
child.stderr.on("data", (chunk) => {
|
|
stderrText += chunk.toString();
|
|
});
|
|
|
|
child.stdin.write(prompt);
|
|
child.stdin.end();
|
|
|
|
child.on("close", () => {
|
|
if (finished) return;
|
|
if (!streamedAny) {
|
|
const events = parseNdjson(stdoutText);
|
|
const response = collectAssistantResponse(events);
|
|
const content = response.assistantText || stderrText.trim() || stdoutText.trim() || "No response";
|
|
for (const chunk of response.thinkingChunks) {
|
|
sendSse(res, createChatCompletionChunk(model, { reasoning_content: chunk }, null, responseMeta));
|
|
}
|
|
for (const chunk of response.assistantChunks) {
|
|
sendSse(res, createChatCompletionChunk(model, { content: chunk }, null, responseMeta));
|
|
}
|
|
if (response.assistantChunks.length === 0 && response.thinkingChunks.length === 0 && content) {
|
|
sendSse(res, createChatCompletionChunk(model, { content }, null, responseMeta));
|
|
}
|
|
}
|
|
finishStop();
|
|
});
|
|
}
|
|
|
|
async function handleChatRequest(res: ServerResponse, workspace: string, body: any) {
|
|
const model = typeof body?.model === "string"
|
|
? body.model.replace(`${PROVIDER_ID}/`, "")
|
|
: "auto";
|
|
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
const tools = Array.isArray(body?.tools) ? body.tools : [];
|
|
const stream = body?.stream === true;
|
|
const prompt = buildPrompt(messages, tools);
|
|
const allowedToolNames = new Set<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_CMD, args, prompt);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
sendJson(res, 200, createChatCompletionResponse(model, `${CURSOR_AGENT_CMD} failed to start: ${message}`));
|
|
return;
|
|
}
|
|
|
|
const events = parseNdjson(result.stdout);
|
|
const interception = extractInterceptedToolCall(events, allowedToolNames, messages, toolSchemaMap);
|
|
if (interception.toolCall) {
|
|
sendJson(res, 200, createToolCallCompletionResponse(model, interception.toolCall));
|
|
return;
|
|
}
|
|
if (interception.guard?.block && !interception.guard.silent && interception.guard.message) {
|
|
sendJson(res, 200, createChatCompletionResponse(model, interception.guard.message));
|
|
return;
|
|
}
|
|
|
|
const response = collectAssistantResponse(events);
|
|
const content = response.assistantText || result.stderr.trim() || result.stdout.trim() || "No response";
|
|
sendJson(res, 200, createChatCompletionResponse(model, content, response.thinkingText || undefined));
|
|
}
|
|
|
|
async function startRuntime(workspace: string): Promise<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_CMD, ["login"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
|
|
const finish = (fn: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
fn();
|
|
};
|
|
|
|
const extractUrl = () => {
|
|
const clean = `${stdout}${stderr}`.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s/g, "");
|
|
const match = clean.match(/https:\/\/cursor\.com\/loginDeepControl[^\s]*/);
|
|
return match?.[0] ?? null;
|
|
};
|
|
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on("error", (error) => finish(() => reject(error)));
|
|
|
|
const pollForUrl = async () => {
|
|
const start = Date.now();
|
|
while (Date.now() - start < LOGIN_URL_TIMEOUT_MS) {
|
|
const url = extractUrl();
|
|
if (url) {
|
|
callbacks.onAuth({ url });
|
|
return;
|
|
}
|
|
await sleep(100);
|
|
}
|
|
finish(() => reject(new Error(stderr.trim() || "Failed to get Cursor login URL")));
|
|
};
|
|
|
|
void pollForUrl();
|
|
|
|
child.on("close", async (code) => {
|
|
if (settled) return;
|
|
if (code !== 0) {
|
|
finish(() => reject(new Error(stderr.trim() || `${CURSOR_AGENT_CMD} login exited with code ${code}`)));
|
|
return;
|
|
}
|
|
|
|
const ok = await pollForCursorAuth();
|
|
if (!ok) {
|
|
finish(() => reject(new Error("Cursor login did not complete in time")));
|
|
return;
|
|
}
|
|
|
|
finish(() =>
|
|
resolve({
|
|
refresh: "cursor-auth",
|
|
access: "cursor-auth",
|
|
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refreshCursorCredentials(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
if (!hasCursorAuth()) {
|
|
throw new Error(`Cursor is not logged in. Run /login cursor-acp or ${CURSOR_AGENT_CMD} login.`);
|
|
}
|
|
return {
|
|
refresh: credentials.refresh || "cursor-auth",
|
|
access: "cursor-auth",
|
|
expires: Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
};
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
try {
|
|
await refreshProvider(pi, ctx.cwd);
|
|
|
|
const configuredModelId = getConfiguredDefaultCursorModel();
|
|
if (configuredModelId) {
|
|
const configuredModel = ctx.modelRegistry.find(PROVIDER_ID, configuredModelId);
|
|
if (configuredModel) {
|
|
await pi.setModel(configuredModel);
|
|
}
|
|
}
|
|
|
|
ctx.ui.notify(`cursor-acp ready (${runtime?.models.length ?? 0} models)`, "info");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.ui.notify(`cursor-acp unavailable: ${message}`, "warning");
|
|
}
|
|
});
|
|
|
|
pi.on("session_shutdown", async () => {
|
|
if (!runtime) return;
|
|
await new Promise<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_CMD, ["--version"]).catch(() => null);
|
|
const models = await discoverModels();
|
|
const status = [
|
|
`provider: ${PROVIDER_ID}`,
|
|
`cli (${CURSOR_AGENT_CMD}): ${version?.stdout.trim() || "not installed"}`,
|
|
`logged in: ${hasCursorAuth() ? "yes" : "no"}`,
|
|
`proxy: ${runtime?.baseUrl ?? "not started"}`,
|
|
`models: ${models.length}`,
|
|
].join("\n");
|
|
ctx.ui.notify(status, "info");
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("cursor-acp-sync", {
|
|
description: "Rediscover Cursor models and refresh the pi provider",
|
|
handler: async (_args, ctx) => {
|
|
await refreshProvider(pi, ctx.cwd);
|
|
ctx.ui.notify(`cursor-acp synced (${runtime?.models.length ?? 0} models)`, "info");
|
|
},
|
|
});
|
|
}
|