From 5eb9fda59a519d59555b31ef6f3bcfcc39a0e8f6 Mon Sep 17 00:00:00 2001 From: Bowser Bot Date: Sat, 27 Jun 2026 22:34:20 +0000 Subject: [PATCH 1/2] feat(agents): add Cursor Agent runtime support Fixes #544 Supersedes #253 --- src/config/runtime-config.ts | 10 +- src/core/agent-catalog.ts | 19 ++++ src/core/api-contract.ts | 11 ++- src/prompts/append-system-prompt.ts | 3 + src/terminal/agent-registry.ts | 29 ++++-- src/terminal/agent-session-adapters.ts | 81 ++++++++++++++++ test/runtime/append-system-prompt.test.ts | 14 +++ test/runtime/config/runtime-config.test.ts | 10 +- test/runtime/terminal/agent-registry.test.ts | 33 ++++++- .../terminal/agent-session-adapters.test.ts | 92 +++++++++++++++++++ .../components/runtime-settings-dialog.tsx | 2 +- .../task-start-agent-onboarding-carousel.tsx | 8 +- 12 files changed, 288 insertions(+), 24 deletions(-) diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 215228cd51..af962e2b19 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -4,7 +4,7 @@ import { readFile, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { getRuntimeAgentCatalogEntry, isRuntimeAgentLaunchSupported } from "../core/agent-catalog"; +import { getRuntimeAgentBinaryCandidates, isRuntimeAgentLaunchSupported } from "../core/agent-catalog"; import type { RuntimeAgentId, RuntimeProjectShortcut } from "../core/api-contract"; import { type LockRequest, lockedFileSystem } from "../fs/locked-file-system"; import { detectInstalledCommands } from "../terminal/agent-registry"; @@ -54,7 +54,7 @@ const PROJECT_CONFIG_PARENT_DIR = ".cline"; const PROJECT_CONFIG_DIR = "kanban"; const PROJECT_CONFIG_FILENAME = "config.json"; const DEFAULT_AGENT_ID: RuntimeAgentId = "cline"; -const AUTO_SELECT_AGENT_PRIORITY: readonly RuntimeAgentId[] = ["claude", "codex", "droid", "kiro"]; +const AUTO_SELECT_AGENT_PRIORITY: readonly RuntimeAgentId[] = ["claude", "cursor", "codex", "droid", "kiro"]; const DEFAULT_AGENT_AUTONOMOUS_MODE_ENABLED = true; const DEFAULT_READY_FOR_REVIEW_NOTIFICATIONS_ENABLED = true; const DEFAULT_COMMIT_PROMPT_TEMPLATE = `You are in a worktree on a detached HEAD. When you are finished with the task, commit the working changes onto {{base_ref}}. @@ -103,9 +103,8 @@ Steps: export function pickBestInstalledAgentIdFromDetected(detectedCommands: readonly string[]): RuntimeAgentId | null { const detected = new Set(detectedCommands); for (const agentId of AUTO_SELECT_AGENT_PRIORITY) { - const catalogEntry = getRuntimeAgentCatalogEntry(agentId); - const binary = catalogEntry?.binary ?? agentId; - if (detected.has(binary) || detected.has(agentId)) { + const binaryCandidates = getRuntimeAgentBinaryCandidates(agentId); + if (binaryCandidates.some((binary) => detected.has(binary)) || detected.has(agentId)) { return agentId; } } @@ -120,6 +119,7 @@ function normalizeAgentId(agentId: RuntimeAgentId | string | null | undefined): if ( (agentId === "claude" || agentId === "codex" || + agentId === "cursor" || agentId === "gemini" || agentId === "opencode" || agentId === "droid" || diff --git a/src/core/agent-catalog.ts b/src/core/agent-catalog.ts index 8f92b7114a..214c576a56 100644 --- a/src/core/agent-catalog.ts +++ b/src/core/agent-catalog.ts @@ -4,6 +4,7 @@ export interface RuntimeAgentCatalogEntry { id: RuntimeAgentId; label: string; binary: string; + binaryAliases?: string[]; baseArgs: string[]; autonomousArgs: string[]; installUrl: string; @@ -26,6 +27,15 @@ export const RUNTIME_AGENT_CATALOG: RuntimeAgentCatalogEntry[] = [ autonomousArgs: ["--dangerously-bypass-approvals-and-sandbox"], installUrl: "https://github.com/openai/codex", }, + { + id: "cursor", + label: "Cursor Agent", + binary: "cursor-agent", + binaryAliases: ["agent"], + baseArgs: [], + autonomousArgs: ["--force"], + installUrl: "https://cursor.com/docs/cli/overview", + }, { id: "cline", label: "Cline", @@ -74,6 +84,7 @@ export const RUNTIME_LAUNCH_SUPPORTED_AGENT_IDS: readonly RuntimeAgentId[] = [ "cline", "claude", "codex", + "cursor", "droid", "kiro", // "opencode", @@ -93,3 +104,11 @@ export function getRuntimeLaunchSupportedAgentCatalog(): RuntimeAgentCatalogEntr export function getRuntimeAgentCatalogEntry(agentId: RuntimeAgentId): RuntimeAgentCatalogEntry | null { return RUNTIME_AGENT_CATALOG.find((entry) => entry.id === agentId) ?? null; } + +export function getRuntimeAgentBinaryCandidates(agentId: RuntimeAgentId): string[] { + const entry = getRuntimeAgentCatalogEntry(agentId); + if (!entry) { + return [agentId]; + } + return [entry.binary, ...(entry.binaryAliases ?? [])]; +} diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index 774ecf8573..b1ced5eb13 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -71,7 +71,16 @@ export const runtimeSlashCommandsResponseSchema = z.object({ }); export type RuntimeSlashCommandsResponse = z.infer; -export const runtimeAgentIdSchema = z.enum(["claude", "codex", "gemini", "opencode", "droid", "kiro", "cline"]); +export const runtimeAgentIdSchema = z.enum([ + "claude", + "codex", + "cursor", + "gemini", + "opencode", + "droid", + "kiro", + "cline", +]); export type RuntimeAgentId = z.infer; const runtimeBoardColumnIdEnum = z.enum(["backlog", "in_progress", "review", "trash"]); diff --git a/src/prompts/append-system-prompt.ts b/src/prompts/append-system-prompt.ts index 97591a329c..4103df72dd 100644 --- a/src/prompts/append-system-prompt.ts +++ b/src/prompts/append-system-prompt.ts @@ -28,6 +28,7 @@ const APPEND_PROMPT_AGENT_IDS: readonly RuntimeAgentId[] = [ "claude", "codex", "cline", + "cursor", "droid", "kiro", "gemini", @@ -58,6 +59,8 @@ function renderLinearSetupGuidanceForAgent(agentId: RuntimeAgentId | null): stri return "- If Linear MCP is not available in the current agent (Claude Code), suggest running: `claude mcp add --transport http --scope user linear https://mcp.linear.app/mcp`"; case "codex": return "- If Linear MCP is not available in the current agent (OpenAI Codex), suggest running: `codex mcp add linear --url https://mcp.linear.app/mcp`"; + case "cursor": + return "- If Linear MCP is not available in the current agent (Cursor Agent), suggest adding `linear` in `.cursor/mcp.json` or `~/.cursor/mcp.json`, then run: `agent mcp login linear`"; case "gemini": return "- If Linear MCP is not available in the current agent (Gemini CLI), suggest running: `gemini mcp add linear https://mcp.linear.app/mcp --transport http --scope user`"; case "opencode": diff --git a/src/terminal/agent-registry.ts b/src/terminal/agent-registry.ts index 4775a128b6..da92556775 100644 --- a/src/terminal/agent-registry.ts +++ b/src/terminal/agent-registry.ts @@ -1,5 +1,9 @@ import type { RuntimeConfigState } from "../config/runtime-config"; -import { getRuntimeLaunchSupportedAgentCatalog, RUNTIME_AGENT_CATALOG } from "../core/agent-catalog"; +import { + getRuntimeAgentBinaryCandidates, + getRuntimeLaunchSupportedAgentCatalog, + RUNTIME_AGENT_CATALOG, +} from "../core/agent-catalog"; import type { RuntimeAgentDefinition, RuntimeAgentId, @@ -49,7 +53,10 @@ function isRuntimeDebugModeEnabled(): boolean { } export function detectInstalledCommands(): string[] { - const candidates = [...RUNTIME_AGENT_CATALOG.map((entry) => entry.binary), "npx"]; + const candidates = [ + ...new Set(RUNTIME_AGENT_CATALOG.flatMap((entry) => [entry.binary, ...(entry.binaryAliases ?? [])])), + "npx", + ]; const detected: string[] = []; for (const candidate of candidates) { @@ -65,12 +72,17 @@ function getCuratedDefinitions(runtimeConfig: RuntimeConfigState, detected: stri const detectedSet = new Set(detected); return getRuntimeLaunchSupportedAgentCatalog().map((entry) => { const defaultArgs = getDefaultArgs(entry.id); - const command = joinCommand(entry.binary, defaultArgs); - const isInstalled = entry.id === "cline" ? true : detectedSet.has(entry.binary); + const binary = + getRuntimeAgentBinaryCandidates(entry.id).find((candidate) => detectedSet.has(candidate)) ?? entry.binary; + const command = joinCommand(binary, defaultArgs); + const hasDetectedBinary = getRuntimeAgentBinaryCandidates(entry.id).some((candidate) => + detectedSet.has(candidate), + ); + const isInstalled = entry.id === "cline" ? true : hasDetectedBinary; return { id: entry.id, label: entry.label, - binary: entry.binary, + binary, command, defaultArgs, installed: isInstalled, @@ -85,13 +97,14 @@ export function resolveAgentCommand(runtimeConfig: RuntimeConfigState): Resolved return null; } const defaultArgs = getDefaultArgs(selected.id); - const command = joinCommand(selected.binary, defaultArgs); - if (isBinaryAvailableOnPath(selected.binary)) { + const binary = getRuntimeAgentBinaryCandidates(selected.id).find((candidate) => isBinaryAvailableOnPath(candidate)); + if (binary) { + const command = joinCommand(binary, defaultArgs); return { agentId: selected.id, label: selected.label, command, - binary: selected.binary, + binary, args: defaultArgs, }; } diff --git a/src/terminal/agent-session-adapters.ts b/src/terminal/agent-session-adapters.ts index 75a0d856a4..f90259132e 100644 --- a/src/terminal/agent-session-adapters.ts +++ b/src/terminal/agent-session-adapters.ts @@ -603,6 +603,38 @@ function toBracketedPasteSubmission(command: string): string { return `\u001b[200~${command}\u001b[201~\r`; } +// Cursor CLI does not expose a separate append-system-prompt flag, so home +// sidebar guidance is passed as the initial prompt content. +function mergeCursorPromptWithHomeSystemPrompt(prompt: string, appendedSystemPrompt: string | null): string { + if (!appendedSystemPrompt) { + return prompt; + } + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + return appendedSystemPrompt; + } + return `${appendedSystemPrompt}\n\n# User Request\n\n${trimmedPrompt}`; +} + +function removeCursorPlanModeConflicts(args: string[]): string[] { + const filtered: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--force" || arg === "-f" || arg === "--yolo" || arg === "--plan") { + continue; + } + if (arg === "--mode") { + index += 1; + continue; + } + if (arg.startsWith("--mode=")) { + continue; + } + filtered.push(arg); + } + return filtered; +} + const claudeAdapter: AgentSessionAdapter = { async prepare(input) { const args = [...input.args]; @@ -710,6 +742,54 @@ const claudeAdapter: AgentSessionAdapter = { }, }; +const cursorAdapter: AgentSessionAdapter = { + async prepare(input) { + const args = [...input.args]; + const env: Record = {}; + + if (input.startInPlanMode) { + const filteredArgs = removeCursorPlanModeConflicts(args); + args.length = 0; + args.push(...filteredArgs, "--plan"); + } else if ( + input.autonomousModeEnabled && + !hasCliOption(args, "--force") && + !hasCliOption(args, "-f") && + !hasCliOption(args, "--yolo") + ) { + args.push("--force"); + } + + if (input.resumeFromTrash && !hasCliOption(args, "--resume") && !hasCliOption(args, "--continue")) { + args.push("--continue"); + } + + const hooks = resolveHookContext(input); + if (hooks) { + Object.assign( + env, + createHookRuntimeEnv({ + taskId: hooks.taskId, + workspaceId: hooks.workspaceId, + }), + ); + } + + const prompt = mergeCursorPromptWithHomeSystemPrompt( + input.prompt, + resolveHomeAgentAppendSystemPrompt(input.taskId), + ); + const withPromptLaunch = withPrompt(args, prompt, "append"); + return { + ...withPromptLaunch, + env: { + ...withPromptLaunch.env, + ...env, + }, + }; + }, +}; + function codexPromptDetector(data: string, summary: RuntimeTaskSessionSummary): SessionTransitionEvent | null { if (summary.state !== "awaiting_review") { return null; @@ -1430,6 +1510,7 @@ const clineAdapter: AgentSessionAdapter = { const ADAPTERS: Record = { claude: claudeAdapter, codex: codexAdapter, + cursor: cursorAdapter, gemini: geminiAdapter, opencode: opencodeAdapter, droid: droidAdapter, diff --git a/test/runtime/append-system-prompt.test.ts b/test/runtime/append-system-prompt.test.ts index 92516acf89..94a113cee0 100644 --- a/test/runtime/append-system-prompt.test.ts +++ b/test/runtime/append-system-prompt.test.ts @@ -120,6 +120,20 @@ describe("resolveHomeAgentAppendSystemPrompt", () => { expect(prompt).toContain("droid mcp add linear https://mcp.linear.app/mcp --type http"); }); + it("returns active-agent guidance for cursor home sidebar sessions", () => { + const prompt = resolveHomeAgentAppendSystemPrompt("__home_agent__:workspace-1:cursor", { + currentVersion: "0.1.10", + cwd: "/Users/example/repo", + execPath: "/usr/local/bin/node", + execArgv: [], + argv: ["node", "/Users/example/repo/dist/cli.js"], + resolveRealPath: (path) => path, + }); + expect(prompt).toContain("Current home agent: `cursor`"); + expect(prompt).toContain(".cursor/mcp.json"); + expect(prompt).toContain("agent mcp login linear"); + }); + it("returns active-agent guidance for kiro home sidebar sessions", () => { const prompt = resolveHomeAgentAppendSystemPrompt("__home_agent__:workspace-1:kiro", { currentVersion: "0.1.10", diff --git a/test/runtime/config/runtime-config.test.ts b/test/runtime/config/runtime-config.test.ts index 884b383d73..5cd8cfc873 100644 --- a/test/runtime/config/runtime-config.test.ts +++ b/test/runtime/config/runtime-config.test.ts @@ -68,6 +68,8 @@ function writeFakeCommand(binDir: string, command: string): void { describe.sequential("runtime-config auto agent selection", () => { it("selects agents using the configured priority order", () => { expect(pickBestInstalledAgentIdFromDetected(["codex", "opencode", "gemini"])).toBe("codex"); + expect(pickBestInstalledAgentIdFromDetected(["cursor-agent", "codex", "droid"])).toBe("cursor"); + expect(pickBestInstalledAgentIdFromDetected(["agent", "codex", "droid"])).toBe("cursor"); expect(pickBestInstalledAgentIdFromDetected(["opencode", "droid", "gemini"])).toBe("droid"); expect(pickBestInstalledAgentIdFromDetected(["kiro-cli", "gemini"])).toBe("kiro"); expect(pickBestInstalledAgentIdFromDetected(["droid", "gemini", "cline"])).toBe("droid"); @@ -88,7 +90,7 @@ describe.sequential("runtime-config auto agent selection", () => { try { writeFakeCommand(tempBin, "opencode"); - writeFakeCommand(tempBin, "codex"); + writeFakeCommand(tempBin, "cursor-agent"); writeFakeCommand(tempBin, "gemini"); const previousShell = process.env.SHELL; @@ -97,7 +99,7 @@ describe.sequential("runtime-config auto agent selection", () => { const isolatedPath = `${tempBin}${delimiter}/usr/bin${delimiter}/bin`; await withTemporaryEnv({ home: tempHome, pathPrefix: isolatedPath, replacePath: true }, async () => { const state = await loadRuntimeConfig(tempProject); - expect(state.selectedAgentId).toBe("codex"); + expect(state.selectedAgentId).toBe("cursor"); const persisted = JSON.parse( readFileSync(join(tempHome, ".cline", "kanban", "config.json"), "utf8"), ) as { @@ -107,14 +109,14 @@ describe.sequential("runtime-config auto agent selection", () => { commitPromptTemplate?: string; openPrPromptTemplate?: string; }; - expect(persisted.selectedAgentId).toBe("codex"); + expect(persisted.selectedAgentId).toBe("cursor"); expect(persisted.agentAutonomousModeEnabled).toBeUndefined(); expect(persisted.readyForReviewNotificationsEnabled).toBeUndefined(); expect(persisted.commitPromptTemplate).toBeUndefined(); expect(persisted.openPrPromptTemplate).toBeUndefined(); const reloadedState = await loadRuntimeConfig(tempProject); - expect(reloadedState.selectedAgentId).toBe("codex"); + expect(reloadedState.selectedAgentId).toBe("cursor"); }); } finally { if (previousShell === undefined) { diff --git a/test/runtime/terminal/agent-registry.test.ts b/test/runtime/terminal/agent-registry.test.ts index 0c54bd147b..cc7b9a223b 100644 --- a/test/runtime/terminal/agent-registry.test.ts +++ b/test/runtime/terminal/agent-registry.test.ts @@ -47,7 +47,27 @@ describe("agent-registry", () => { const detected = detectInstalledCommands(); expect(detected).toEqual(["claude"]); - expect(commandDiscoveryMocks.isBinaryAvailableOnPath).toHaveBeenCalledTimes(8); + expect(commandDiscoveryMocks.isBinaryAvailableOnPath).toHaveBeenCalledTimes(10); + }); + + it("resolves Cursor Agent through the canonical binary before the documented alias", () => { + commandDiscoveryMocks.isBinaryAvailableOnPath.mockImplementation((binary: string) => binary === "cursor-agent"); + + const resolved = resolveAgentCommand(createRuntimeConfigState({ selectedAgentId: "cursor" })); + + expect(resolved?.agentId).toBe("cursor"); + expect(resolved?.binary).toBe("cursor-agent"); + expect(resolved?.command).toBe("cursor-agent"); + }); + + it("falls back to the Cursor documented agent binary when cursor-agent is unavailable", () => { + commandDiscoveryMocks.isBinaryAvailableOnPath.mockImplementation((binary: string) => binary === "agent"); + + const resolved = resolveAgentCommand(createRuntimeConfigState({ selectedAgentId: "cursor" })); + + expect(resolved?.agentId).toBe("cursor"); + expect(resolved?.binary).toBe("agent"); + expect(resolved?.command).toBe("agent"); }); it("treats shell-only agents as unavailable", () => { @@ -78,9 +98,10 @@ describe("buildRuntimeConfigResponse", () => { }); expect(response.agentAutonomousModeEnabled).toBe(true); - expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro"]); + expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cursor", "cline", "droid", "kiro"]); expect(response.agents.find((agent) => agent.id === "claude")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "codex")?.defaultArgs).toEqual([]); + expect(response.agents.find((agent) => agent.id === "cursor")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "droid")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "kiro")?.defaultArgs).toEqual(["chat"]); @@ -91,7 +112,9 @@ describe("buildRuntimeConfigResponse", () => { const config = createRuntimeConfigState({ agentAutonomousModeEnabled: false, }); - commandDiscoveryMocks.isBinaryAvailableOnPath.mockImplementation((binary: string) => binary === "claude"); + commandDiscoveryMocks.isBinaryAvailableOnPath.mockImplementation( + (binary: string) => binary === "claude" || binary === "agent", + ); const response = buildRuntimeConfigResponse(config, { providerId: null, @@ -106,15 +129,17 @@ describe("buildRuntimeConfigResponse", () => { }); expect(response.agentAutonomousModeEnabled).toBe(false); - expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro"]); + expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cursor", "cline", "droid", "kiro"]); expect(response.agents.find((agent) => agent.id === "claude")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "codex")?.defaultArgs).toEqual([]); + expect(response.agents.find((agent) => agent.id === "cursor")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "droid")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "kiro")?.defaultArgs).toEqual(["chat"]); expect(response.agents.find((agent) => agent.id === "cline")?.installed).toBe(true); expect(response.agents.find((agent) => agent.id === "claude")?.command).toBe("claude"); expect(response.agents.find((agent) => agent.id === "codex")?.command).toBe("codex"); + expect(response.agents.find((agent) => agent.id === "cursor")?.command).toBe("agent"); expect(response.agents.find((agent) => agent.id === "droid")?.command).toBe("droid"); expect(response.agents.find((agent) => agent.id === "kiro")?.command).toBe("kiro-cli chat"); }); diff --git a/test/runtime/terminal/agent-session-adapters.test.ts b/test/runtime/terminal/agent-session-adapters.test.ts index 864a69ae2c..7269115050 100644 --- a/test/runtime/terminal/agent-session-adapters.test.ts +++ b/test/runtime/terminal/agent-session-adapters.test.ts @@ -159,6 +159,98 @@ describe("prepareAgentLaunch hook strategies", () => { expect(getCodexConfigOverrideValues(launch.args, "check_for_update_on_startup")).toEqual(["false"]); }); + it("injects Kanban sidebar instructions into home Cursor prompts", async () => { + setupTempHome(); + setKanbanProcessContext(); + const launch = await prepareAgentLaunch({ + taskId: "__home_agent__:workspace-1:cursor", + agentId: "cursor", + binary: "cursor-agent", + args: [], + cwd: "/tmp", + prompt: "Create a task for the failing login test", + }); + + const initialPrompt = launch.args.at(-1) ?? ""; + expect(initialPrompt).toContain("Kanban sidebar agent"); + expect(initialPrompt).toContain("Current home agent: `cursor`"); + expect(initialPrompt).toContain("agent mcp login linear"); + expect(initialPrompt).toContain("# User Request"); + expect(initialPrompt).toContain("Create a task for the failing login test"); + }); + + it("uses Kanban sidebar bootstrap guidance as the initial home Cursor prompt", async () => { + setupTempHome(); + setKanbanProcessContext(); + const launch = await prepareAgentLaunch({ + taskId: "__home_agent__:workspace-1:cursor", + agentId: "cursor", + binary: "cursor-agent", + args: [], + cwd: "/tmp", + prompt: "", + }); + + const initialPrompt = launch.args.at(-1) ?? ""; + expect(initialPrompt).toContain("Kanban sidebar agent"); + expect(initialPrompt).toContain("Current home agent: `cursor`"); + expect(initialPrompt).not.toContain("# User Request"); + }); + + it("wires Cursor hook runtime env when workspace context exists", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-cursor", + agentId: "cursor", + binary: "cursor-agent", + args: [], + cwd: "/tmp", + prompt: "", + workspaceId: "workspace-1", + }); + + expect(launch.env.KANBAN_HOOK_TASK_ID).toBe("task-cursor"); + expect(launch.env.KANBAN_HOOK_WORKSPACE_ID).toBe("workspace-1"); + }); + + it("enforces Cursor plan mode and removes conflicting mode or force args", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-cursor-plan", + agentId: "cursor", + binary: "cursor-agent", + args: ["--force", "--mode", "ask", "--yolo"], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "Audit the auth module", + startInPlanMode: true, + }); + + expect(launch.args).toContain("--plan"); + expect(launch.args).not.toContain("--force"); + expect(launch.args).not.toContain("--yolo"); + expect(launch.args).not.toContain("--mode"); + expect(launch.args).not.toContain("ask"); + expect(launch.args.at(-1)).toBe("Audit the auth module"); + }); + + it("adds Cursor resume and autonomous flags when appropriate", async () => { + setupTempHome(); + const launch = await prepareAgentLaunch({ + taskId: "task-cursor-auto", + agentId: "cursor", + binary: "cursor-agent", + args: [], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "", + resumeFromTrash: true, + }); + + expect(launch.args).toContain("--force"); + expect(launch.args).toContain("--continue"); + }); + it("disables Codex startup update checks for Kanban-launched sessions", async () => { setupTempHome(); const launch = await prepareAgentLaunch({ diff --git a/web-ui/src/components/runtime-settings-dialog.tsx b/web-ui/src/components/runtime-settings-dialog.tsx index 314e80c682..4bfb3d8680 100644 --- a/web-ui/src/components/runtime-settings-dialog.tsx +++ b/web-ui/src/components/runtime-settings-dialog.tsx @@ -92,7 +92,7 @@ const GIT_PROMPT_VARIANT_OPTIONS: Array<{ value: TaskGitAction; label: string }> export type RuntimeSettingsSection = "shortcuts"; -const SETTINGS_AGENT_ORDER: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro"]; +const SETTINGS_AGENT_ORDER: readonly RuntimeAgentId[] = ["cline", "claude", "cursor", "codex", "droid", "kiro"]; type SettingsNavId = "general" | "cline" | "git-prompts" | "notifications" | "appearance" | "project"; diff --git a/web-ui/src/components/task-start-agent-onboarding-carousel.tsx b/web-ui/src/components/task-start-agent-onboarding-carousel.tsx index 795594deb3..2e9e587c7e 100644 --- a/web-ui/src/components/task-start-agent-onboarding-carousel.tsx +++ b/web-ui/src/components/task-start-agent-onboarding-carousel.tsx @@ -83,7 +83,7 @@ export const TASK_START_ONBOARDING_SLIDES: OnboardingSlide[] = [ }, ]; -const ONBOARDING_AGENT_IDS: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro"]; +const ONBOARDING_AGENT_IDS: readonly RuntimeAgentId[] = ["cline", "claude", "cursor", "codex", "droid", "kiro"]; const FALLBACK_ONBOARDING_SLIDE: OnboardingSlide = { kind: "agent-selection", title: "", @@ -299,6 +299,9 @@ function resolveInstallInstructions(agentId: RuntimeAgentId): string { if (agentId === "codex") { return "OpenAI's coding agent CLI with access to the latest GPT models."; } + if (agentId === "cursor") { + return "Cursor's coding agent CLI powered by Cursor Agent."; + } if (agentId === "droid") { return "Factory's coding agent with access to the latest frontier models."; } @@ -315,6 +318,9 @@ function getInstallLinkLabel(agentId: RuntimeAgentId): string { if (agentId === "codex") { return "Learn more"; } + if (agentId === "cursor") { + return "Learn more"; + } if (agentId === "droid") { return "Learn more"; } From ce77316a9c502fcb3a2a692b3ccbc6cbf349fb4e Mon Sep 17 00:00:00 2001 From: Bowser Bot Date: Sun, 28 Jun 2026 22:38:56 +0000 Subject: [PATCH 2/2] fix(agents): wire Cursor hooks and safer alias handling --- src/config/runtime-config.ts | 6 +- src/prompts/append-system-prompt.ts | 2 +- src/terminal/agent-session-adapters.ts | 95 ++++++++++++++++++- test/runtime/append-system-prompt.test.ts | 1 + test/runtime/config/runtime-config.test.ts | 3 +- .../terminal/agent-session-adapters.test.ts | 51 +++++++++- 6 files changed, 149 insertions(+), 9 deletions(-) diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index af962e2b19..661d0ccf43 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -4,7 +4,7 @@ import { readFile, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { getRuntimeAgentBinaryCandidates, isRuntimeAgentLaunchSupported } from "../core/agent-catalog"; +import { getRuntimeAgentCatalogEntry, isRuntimeAgentLaunchSupported } from "../core/agent-catalog"; import type { RuntimeAgentId, RuntimeProjectShortcut } from "../core/api-contract"; import { type LockRequest, lockedFileSystem } from "../fs/locked-file-system"; import { detectInstalledCommands } from "../terminal/agent-registry"; @@ -103,8 +103,8 @@ Steps: export function pickBestInstalledAgentIdFromDetected(detectedCommands: readonly string[]): RuntimeAgentId | null { const detected = new Set(detectedCommands); for (const agentId of AUTO_SELECT_AGENT_PRIORITY) { - const binaryCandidates = getRuntimeAgentBinaryCandidates(agentId); - if (binaryCandidates.some((binary) => detected.has(binary)) || detected.has(agentId)) { + const canonicalBinary = getRuntimeAgentCatalogEntry(agentId)?.binary ?? agentId; + if (detected.has(canonicalBinary) || detected.has(agentId)) { return agentId; } } diff --git a/src/prompts/append-system-prompt.ts b/src/prompts/append-system-prompt.ts index 4103df72dd..a495d97c38 100644 --- a/src/prompts/append-system-prompt.ts +++ b/src/prompts/append-system-prompt.ts @@ -60,7 +60,7 @@ function renderLinearSetupGuidanceForAgent(agentId: RuntimeAgentId | null): stri case "codex": return "- If Linear MCP is not available in the current agent (OpenAI Codex), suggest running: `codex mcp add linear --url https://mcp.linear.app/mcp`"; case "cursor": - return "- If Linear MCP is not available in the current agent (Cursor Agent), suggest adding `linear` in `.cursor/mcp.json` or `~/.cursor/mcp.json`, then run: `agent mcp login linear`"; + return "- If Linear MCP is not available in the current agent (Cursor Agent), suggest adding `linear` in `.cursor/mcp.json` or `~/.cursor/mcp.json`, then run: `cursor-agent mcp login linear` (or `agent mcp login linear` if `cursor-agent` is not available)"; case "gemini": return "- If Linear MCP is not available in the current agent (Gemini CLI), suggest running: `gemini mcp add linear https://mcp.linear.app/mcp --transport http --scope user`"; case "opencode": diff --git a/src/terminal/agent-session-adapters.ts b/src/terminal/agent-session-adapters.ts index f90259132e..ecab25b541 100644 --- a/src/terminal/agent-session-adapters.ts +++ b/src/terminal/agent-session-adapters.ts @@ -1,4 +1,4 @@ -import { access, readFile } from "node:fs/promises"; +import { access, readFile, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; @@ -109,6 +109,14 @@ function buildHookCommand(event: RuntimeHookEvent, metadata?: HookCommandMetadat return parts.map(quoteShellArg).join(" "); } +function buildCursorHookCommand(event: RuntimeHookEvent, hookEventName: string, activityText?: string): string { + return buildHookCommand(event, { + source: "cursor", + hookEventName, + activityText, + }); +} + function buildHooksCommandParts(args: string[]): string[] { return buildKanbanCommandParts(["hooks", ...args]); } @@ -580,6 +588,88 @@ async function ensureTextFile(filePath: string, content: string, executable = fa }); } +async function readOptionalTextFile(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +function parseJsonRecord(raw: string | null): Record { + if (!raw) { + return {}; + } + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // Invalid existing hook configs are preserved during cleanup. + } + return {}; +} + +function normalizeHookEntries(value: unknown): Array> { + return Array.isArray(value) + ? value.filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + : []; +} + +function mergeCursorHooksConfig(rawConfig: string | null): string { + const config = parseJsonRecord(rawConfig); + const hooksRecord = + config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks) + ? (config.hooks as Record) + : {}; + const hooksToAdd: Record>> = { + beforeSubmitPrompt: [{ command: buildCursorHookCommand("to_in_progress", "beforeSubmitPrompt") }], + beforeShellExecution: [{ command: buildCursorHookCommand("to_in_progress", "beforeShellExecution") }], + beforeMCPExecution: [{ command: buildCursorHookCommand("to_in_progress", "beforeMCPExecution") }], + beforeReadFile: [{ command: buildCursorHookCommand("activity", "beforeReadFile") }], + afterFileEdit: [{ command: buildCursorHookCommand("activity", "afterFileEdit") }], + stop: [{ command: buildCursorHookCommand("to_review", "stop", "Waiting for review") }], + }; + + const nextHooks: Record>> = {}; + for (const [hookName, existingEntries] of Object.entries(hooksRecord)) { + nextHooks[hookName] = normalizeHookEntries(existingEntries); + } + for (const [hookName, entries] of Object.entries(hooksToAdd)) { + nextHooks[hookName] = [...(nextHooks[hookName] ?? []), ...entries]; + } + + return JSON.stringify( + { + ...config, + version: typeof config.version === "number" ? config.version : 1, + hooks: nextHooks, + }, + null, + 2, + ); +} + +async function configureCursorHooks(cwd: string): Promise<(() => Promise) | null> { + const hooksPath = join(cwd, ".cursor", "hooks.json"); + const originalContent = await readOptionalTextFile(hooksPath); + const nextContent = mergeCursorHooksConfig(originalContent); + await ensureTextFile(hooksPath, nextContent); + + return async () => { + const currentContent = await readOptionalTextFile(hooksPath); + if (currentContent !== nextContent) { + return; + } + if (originalContent === null) { + await rm(hooksPath, { force: true }); + return; + } + await ensureTextFile(hooksPath, originalContent); + }; +} + function withPrompt(args: string[], prompt: string, mode: "append" | "flag", flag?: string): PreparedAgentLaunch { const trimmed = prompt.trim(); if (!trimmed) { @@ -746,6 +836,7 @@ const cursorAdapter: AgentSessionAdapter = { async prepare(input) { const args = [...input.args]; const env: Record = {}; + let cleanup: (() => Promise) | null = null; if (input.startInPlanMode) { const filteredArgs = removeCursorPlanModeConflicts(args); @@ -766,6 +857,7 @@ const cursorAdapter: AgentSessionAdapter = { const hooks = resolveHookContext(input); if (hooks) { + cleanup = await configureCursorHooks(input.cwd); Object.assign( env, createHookRuntimeEnv({ @@ -786,6 +878,7 @@ const cursorAdapter: AgentSessionAdapter = { ...withPromptLaunch.env, ...env, }, + cleanup: cleanup ?? undefined, }; }, }; diff --git a/test/runtime/append-system-prompt.test.ts b/test/runtime/append-system-prompt.test.ts index 94a113cee0..e12cd0a2cd 100644 --- a/test/runtime/append-system-prompt.test.ts +++ b/test/runtime/append-system-prompt.test.ts @@ -131,6 +131,7 @@ describe("resolveHomeAgentAppendSystemPrompt", () => { }); expect(prompt).toContain("Current home agent: `cursor`"); expect(prompt).toContain(".cursor/mcp.json"); + expect(prompt).toContain("cursor-agent mcp login linear"); expect(prompt).toContain("agent mcp login linear"); }); diff --git a/test/runtime/config/runtime-config.test.ts b/test/runtime/config/runtime-config.test.ts index 5cd8cfc873..1cb62a91a1 100644 --- a/test/runtime/config/runtime-config.test.ts +++ b/test/runtime/config/runtime-config.test.ts @@ -69,7 +69,8 @@ describe.sequential("runtime-config auto agent selection", () => { it("selects agents using the configured priority order", () => { expect(pickBestInstalledAgentIdFromDetected(["codex", "opencode", "gemini"])).toBe("codex"); expect(pickBestInstalledAgentIdFromDetected(["cursor-agent", "codex", "droid"])).toBe("cursor"); - expect(pickBestInstalledAgentIdFromDetected(["agent", "codex", "droid"])).toBe("cursor"); + expect(pickBestInstalledAgentIdFromDetected(["agent", "codex", "droid"])).toBe("codex"); + expect(pickBestInstalledAgentIdFromDetected(["agent"])).toBeNull(); expect(pickBestInstalledAgentIdFromDetected(["opencode", "droid", "gemini"])).toBe("droid"); expect(pickBestInstalledAgentIdFromDetected(["kiro-cli", "gemini"])).toBe("kiro"); expect(pickBestInstalledAgentIdFromDetected(["droid", "gemini", "cline"])).toBe("droid"); diff --git a/test/runtime/terminal/agent-session-adapters.test.ts b/test/runtime/terminal/agent-session-adapters.test.ts index 7269115050..9d7e425d88 100644 --- a/test/runtime/terminal/agent-session-adapters.test.ts +++ b/test/runtime/terminal/agent-session-adapters.test.ts @@ -174,7 +174,7 @@ describe("prepareAgentLaunch hook strategies", () => { const initialPrompt = launch.args.at(-1) ?? ""; expect(initialPrompt).toContain("Kanban sidebar agent"); expect(initialPrompt).toContain("Current home agent: `cursor`"); - expect(initialPrompt).toContain("agent mcp login linear"); + expect(initialPrompt).toContain("cursor-agent mcp login linear"); expect(initialPrompt).toContain("# User Request"); expect(initialPrompt).toContain("Create a task for the failing login test"); }); @@ -198,19 +198,64 @@ describe("prepareAgentLaunch hook strategies", () => { }); it("wires Cursor hook runtime env when workspace context exists", async () => { - setupTempHome(); + const cwd = setupTempHome(); const launch = await prepareAgentLaunch({ taskId: "task-cursor", agentId: "cursor", binary: "cursor-agent", args: [], - cwd: "/tmp", + cwd, prompt: "", workspaceId: "workspace-1", }); expect(launch.env.KANBAN_HOOK_TASK_ID).toBe("task-cursor"); expect(launch.env.KANBAN_HOOK_WORKSPACE_ID).toBe("workspace-1"); + + const hooksPath = join(cwd, ".cursor", "hooks.json"); + const config = JSON.parse(readFileSync(hooksPath, "utf8")) as { + version?: number; + hooks?: Record>; + }; + expect(config.version).toBe(1); + expect(config.hooks?.beforeSubmitPrompt?.[0]?.command).toContain("to_in_progress"); + expect(config.hooks?.beforeShellExecution?.[0]?.command).toContain("to_in_progress"); + expect(config.hooks?.afterFileEdit?.[0]?.command).toContain("activity"); + expect(config.hooks?.stop?.[0]?.command).toContain("to_review"); + expect(config.hooks?.stop?.[0]?.command).toContain("Waiting for review"); + }); + + it("restores pre-existing Cursor hooks on cleanup", async () => { + const cwd = setupTempHome(); + const hooksDir = join(cwd, ".cursor"); + const hooksPath = join(hooksDir, "hooks.json"); + const originalConfig = { + version: 1, + hooks: { + stop: [{ command: "echo existing-stop" }], + }, + }; + mkdirSync(hooksDir, { recursive: true }); + writeFileSync(hooksPath, JSON.stringify(originalConfig, null, 2), "utf8"); + + const launch = await prepareAgentLaunch({ + taskId: "task-cursor-existing-hooks", + agentId: "cursor", + binary: "cursor-agent", + args: [], + cwd, + prompt: "", + workspaceId: "workspace-1", + }); + + const mergedConfig = JSON.parse(readFileSync(hooksPath, "utf8")) as { + hooks?: Record>; + }; + expect(mergedConfig.hooks?.stop?.[0]?.command).toBe("echo existing-stop"); + expect(mergedConfig.hooks?.stop?.[1]?.command).toContain("to_review"); + + await launch.cleanup?.(); + expect(readFileSync(hooksPath, "utf8")).toBe(JSON.stringify(originalConfig, null, 2)); }); it("enforces Cursor plan mode and removes conflicting mode or force args", async () => {