From 0bfdbd350e589c018ee0afcf65c848719d35b575 Mon Sep 17 00:00:00 2001 From: "Thomas G. Lopes" Date: Thu, 16 Apr 2026 12:40:51 +0100 Subject: [PATCH] cursor stuff --- jj/files/config.toml | 3 ++ pi/files.macos/agent/settings.json | 4 +- pi/files/agent/extensions/cursor-acp.ts | 68 ++++++++++++++++++------- 3 files changed, 54 insertions(+), 21 deletions(-) diff --git a/jj/files/config.toml b/jj/files/config.toml index c7bf1ae..f9b3a66 100644 --- a/jj/files/config.toml +++ b/jj/files/config.toml @@ -4,3 +4,6 @@ email = "thomasgl@pm.me" [git] write-change-id-header = true + +[snapshot] +auto-update-stale = true diff --git a/pi/files.macos/agent/settings.json b/pi/files.macos/agent/settings.json index 89bb8d9..a89cb37 100644 --- a/pi/files.macos/agent/settings.json +++ b/pi/files.macos/agent/settings.json @@ -1,7 +1,7 @@ { "lastChangelogVersion": "0.66.1", - "defaultProvider": "openai-codex", - "defaultModel": "gpt-5.4", + "defaultProvider": "cursor-acp", + "defaultModel": "auto", "defaultThinkingLevel": "medium", "theme": "matugen", "lsp": { diff --git a/pi/files/agent/extensions/cursor-acp.ts b/pi/files/agent/extensions/cursor-acp.ts index 7194e54..7b805cf 100644 --- a/pi/files/agent/extensions/cursor-acp.ts +++ b/pi/files/agent/extensions/cursor-acp.ts @@ -52,6 +52,9 @@ const LOGIN_URL_TIMEOUT_MS = 15_000; const AUTH_POLL_INTERVAL_MS = 2_000; const AUTH_POLL_TIMEOUT_MS = 5 * 60_000; const CURSOR_FORCE = process.env.PI_CURSOR_ACP_FORCE !== "false"; +/** Same resolution as community providers: https://github.com/netandreus/pi-cursor-provider */ +const CURSOR_AGENT_CMD = + process.env.PI_CURSOR_AGENT_PATH ?? process.env.CURSOR_AGENT_PATH ?? process.env.AGENT_PATH ?? "cursor-agent"; const TOOL_NAME_ALIASES = new Map([ ["readtoolcall", "read"], @@ -336,7 +339,7 @@ async function execCapture(command: string, args: string[], stdin?: string): Pro async function discoverModels(): Promise { try { - const result = await execCapture("cursor-agent", ["models"]); + const result = await execCapture(CURSOR_AGENT_CMD, ["models"]); const models = parseModels(result.stdout); return models.length > 0 ? models : FALLBACK_MODELS; } catch { @@ -491,6 +494,13 @@ function renderToolContent(content: unknown): string { } 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") @@ -1092,8 +1102,16 @@ function collectAssistantResponse(events: StreamJsonEvent[]): { if (text) { if (isPartial) { sawAssistantPartials = true; - assistantText += text; - assistantChunks.push(text); + // 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; } @@ -1102,8 +1120,14 @@ function collectAssistantResponse(events: StreamJsonEvent[]): { if (thinking) { if (isPartial) { sawThinkingPartials = true; - thinkingText += thinking; - thinkingChunks.push(thinking); + 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; } @@ -1143,7 +1167,9 @@ function getNovelStreamingDelta(previous: string, next: string): { delta: string snapshot: next, }; } - return { delta: next, 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( @@ -1173,7 +1199,7 @@ async function handleStreamingChatRequest( ]; if (CURSOR_FORCE) args.push("--force"); - const child = spawn("cursor-agent", args, { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(CURSOR_AGENT_CMD, args, { stdio: ["pipe", "pipe", "pipe"] }); let stdoutBuffer = ""; let stdoutText = ""; let stderrText = ""; @@ -1226,8 +1252,6 @@ async function handleStreamingChatRequest( streamedAny = true; sendSse(res, createChatCompletionChunk(model, { reasoning_content: delta }, null, responseMeta)); } - } else { - lastThinkingSnapshot = ""; } if (text) { const { delta, snapshot } = getNovelStreamingDelta(lastAssistantSnapshot, text); @@ -1236,15 +1260,21 @@ async function handleStreamingChatRequest( streamedAny = true; sendSse(res, createChatCompletionChunk(model, { content: delta }, null, responseMeta)); } - } else { - lastAssistantSnapshot = ""; } }; child.on("error", (error) => { if (finished) return; finished = true; - sendSse(res, createChatCompletionChunk(model, { content: `cursor-agent failed to start: ${error.message}` }, "stop", responseMeta)); + sendSse( + res, + createChatCompletionChunk( + model, + { content: `${CURSOR_AGENT_CMD} failed to start: ${error.message}` }, + "stop", + responseMeta, + ), + ); endSse(res); }); @@ -1327,10 +1357,10 @@ async function handleChatRequest(res: ServerResponse, workspace: string, body: a model, ]; if (CURSOR_FORCE) args.push("--force"); - result = await execCapture("cursor-agent", args, prompt); + 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 failed to start: ${message}`)); + sendJson(res, 200, createChatCompletionResponse(model, `${CURSOR_AGENT_CMD} failed to start: ${message}`)); return; } @@ -1457,7 +1487,7 @@ async function refreshProvider(pi: ExtensionAPI, workspace: string) { async function loginCursor(callbacks: OAuthLoginCallbacks): Promise { return await new Promise((resolve, reject) => { - const child = spawn("cursor-agent", ["login"], { stdio: ["ignore", "pipe", "pipe"] }); + const child = spawn(CURSOR_AGENT_CMD, ["login"], { stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; let settled = false; @@ -1500,7 +1530,7 @@ async function loginCursor(callbacks: OAuthLoginCallbacks): Promise { if (settled) return; if (code !== 0) { - finish(() => reject(new Error(stderr.trim() || `cursor-agent login exited with code ${code}`))); + finish(() => reject(new Error(stderr.trim() || `${CURSOR_AGENT_CMD} login exited with code ${code}`))); return; } @@ -1523,7 +1553,7 @@ async function loginCursor(callbacks: OAuthLoginCallbacks): Promise { if (!hasCursorAuth()) { - throw new Error("Cursor is not logged in. Run /login cursor-acp or cursor-agent login."); + throw new Error(`Cursor is not logged in. Run /login cursor-acp or ${CURSOR_AGENT_CMD} login.`); } return { refresh: credentials.refresh || "cursor-auth", @@ -1562,11 +1592,11 @@ export default function (pi: ExtensionAPI) { 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 version = await execCapture(CURSOR_AGENT_CMD, ["--version"]).catch(() => null); const models = await discoverModels(); const status = [ `provider: ${PROVIDER_ID}`, - `cursor-agent: ${version?.stdout.trim() || "not installed"}`, + `cli (${CURSOR_AGENT_CMD}): ${version?.stdout.trim() || "not installed"}`, `logged in: ${hasCursorAuth() ? "yes" : "no"}`, `proxy: ${runtime?.baseUrl ?? "not started"}`, `models: ${models.length}`,