-
Notifications
You must be signed in to change notification settings - Fork 289
feat(agents): add Cursor Agent runtime support #545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null> { | ||
| try { | ||
| return await readFile(filePath, "utf8"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function parseJsonRecord(raw: string | null): Record<string, unknown> { | ||
| if (!raw) { | ||
| return {}; | ||
| } | ||
| try { | ||
| const parsed: unknown = JSON.parse(raw); | ||
| if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { | ||
| return parsed as Record<string, unknown>; | ||
| } | ||
| } catch { | ||
| // Invalid existing hook configs are preserved during cleanup. | ||
| } | ||
| return {}; | ||
| } | ||
|
|
||
| function normalizeHookEntries(value: unknown): Array<Record<string, unknown>> { | ||
| return Array.isArray(value) | ||
| ? value.filter((entry): entry is Record<string, unknown> => 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<string, unknown>) | ||
| : {}; | ||
| const hooksToAdd: Record<string, Array<Record<string, string>>> = { | ||
| 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<string, Array<Record<string, unknown>>> = {}; | ||
| 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<void>) | 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) { | ||
|
|
@@ -603,6 +693,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 +832,57 @@ const claudeAdapter: AgentSessionAdapter = { | |
| }, | ||
| }; | ||
|
|
||
| const cursorAdapter: AgentSessionAdapter = { | ||
| async prepare(input) { | ||
| const args = [...input.args]; | ||
| const env: Record<string, string | undefined> = {}; | ||
| let cleanup: (() => Promise<void>) | null = null; | ||
|
|
||
| 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) { | ||
| cleanup = await configureCursorHooks(input.cwd); | ||
| Object.assign( | ||
| env, | ||
| createHookRuntimeEnv({ | ||
| taskId: hooks.taskId, | ||
| workspaceId: hooks.workspaceId, | ||
| }), | ||
| ); | ||
| } | ||
|
Comment on lines
+858
to
+868
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Every other adapter that supports hooks (claude, codex, gemini, opencode, droid, kiro, cline) writes a hook configuration file and passes it to the CLI, so the agent actually knows to call back into Kanban on Prompt To Fix With AIThis is a comment left during a code review.
Path: src/terminal/agent-session-adapters.ts
Line: 767-776
Comment:
**Missing hook configuration file — task state transitions won't fire**
Every other adapter that supports hooks (claude, codex, gemini, opencode, droid, kiro, cline) writes a hook configuration file and passes it to the CLI, so the agent actually knows to call back into Kanban on `Stop`, `PreToolUse`, etc. The cursor adapter only sets `KANBAN_HOOK_TASK_ID`/`KANBAN_HOOK_WORKSPACE_ID` env vars, but never registers any hook command with Cursor Agent. Without a hook config, Cursor has no instruction to call the Kanban webhook, so tasks will stay stuck in `in_progress` indefinitely rather than transitioning to `awaiting_review`.
How can I resolve this? If you propose a fix, please make it concise. |
||
|
|
||
| const prompt = mergeCursorPromptWithHomeSystemPrompt( | ||
| input.prompt, | ||
| resolveHomeAgentAppendSystemPrompt(input.taskId), | ||
| ); | ||
| const withPromptLaunch = withPrompt(args, prompt, "append"); | ||
| return { | ||
| ...withPromptLaunch, | ||
| env: { | ||
| ...withPromptLaunch.env, | ||
| ...env, | ||
| }, | ||
| cleanup: cleanup ?? undefined, | ||
| }; | ||
| }, | ||
| }; | ||
|
|
||
| function codexPromptDetector(data: string, summary: RuntimeTaskSessionSummary): SessionTransitionEvent | null { | ||
| if (summary.state !== "awaiting_review") { | ||
| return null; | ||
|
|
@@ -1430,6 +1603,7 @@ const clineAdapter: AgentSessionAdapter = { | |
| const ADAPTERS: Record<RuntimeAgentId, AgentSessionAdapter> = { | ||
| claude: claudeAdapter, | ||
| codex: codexAdapter, | ||
| cursor: cursorAdapter, | ||
| gemini: geminiAdapter, | ||
| opencode: opencodeAdapter, | ||
| droid: droidAdapter, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agentalias risks false-positive Cursor detectionagentis an extremely common binary name used by unrelated tools (e.g., HashiCorp Vault Agent, New Relic Agent, AWS SSM Agent). Becausecursornow sits ahead ofcodexinAUTO_SELECT_AGENT_PRIORITY, any system with a non-Cursoragentbinary on PATH will have Cursor auto-selected over Codex, andresolveAgentCommandwill return that unrelated binary as the Cursor binary. The PR notes this tradeoff, but it may be worth documenting a follow-up to narrow detection or at least adding a comment explaining the known risk.Prompt To Fix With AI