Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/config/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}.
Expand Down Expand Up @@ -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 canonicalBinary = getRuntimeAgentCatalogEntry(agentId)?.binary ?? agentId;
if (detected.has(canonicalBinary) || detected.has(agentId)) {
return agentId;
}
}
Expand All @@ -120,6 +119,7 @@ function normalizeAgentId(agentId: RuntimeAgentId | string | null | undefined):
if (
(agentId === "claude" ||
agentId === "codex" ||
agentId === "cursor" ||
agentId === "gemini" ||
agentId === "opencode" ||
agentId === "droid" ||
Expand Down
19 changes: 19 additions & 0 deletions src/core/agent-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface RuntimeAgentCatalogEntry {
id: RuntimeAgentId;
label: string;
binary: string;
binaryAliases?: string[];
baseArgs: string[];
autonomousArgs: string[];
installUrl: string;
Expand All @@ -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: [],
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Generic agent alias risks false-positive Cursor detection

agent is an extremely common binary name used by unrelated tools (e.g., HashiCorp Vault Agent, New Relic Agent, AWS SSM Agent). Because cursor now sits ahead of codex in AUTO_SELECT_AGENT_PRIORITY, any system with a non-Cursor agent binary on PATH will have Cursor auto-selected over Codex, and resolveAgentCommand will 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
This is a comment left during a code review.
Path: src/core/agent-catalog.ts
Line: 33-35

Comment:
**Generic `agent` alias risks false-positive Cursor detection**

`agent` is an extremely common binary name used by unrelated tools (e.g., HashiCorp Vault Agent, New Relic Agent, AWS SSM Agent). Because `cursor` now sits ahead of `codex` in `AUTO_SELECT_AGENT_PRIORITY`, any system with a non-Cursor `agent` binary on PATH will have Cursor auto-selected over Codex, and `resolveAgentCommand` will 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.

How can I resolve this? If you propose a fix, please make it concise.

autonomousArgs: ["--force"],
installUrl: "https://cursor.com/docs/cli/overview",
},
{
id: "cline",
label: "Cline",
Expand Down Expand Up @@ -74,6 +84,7 @@ export const RUNTIME_LAUNCH_SUPPORTED_AGENT_IDS: readonly RuntimeAgentId[] = [
"cline",
"claude",
"codex",
"cursor",
"droid",
"kiro",
// "opencode",
Expand All @@ -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 ?? [])];
}
11 changes: 10 additions & 1 deletion src/core/api-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,16 @@ export const runtimeSlashCommandsResponseSchema = z.object({
});
export type RuntimeSlashCommandsResponse = z.infer<typeof runtimeSlashCommandsResponseSchema>;

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<typeof runtimeAgentIdSchema>;

const runtimeBoardColumnIdEnum = z.enum(["backlog", "in_progress", "review", "trash"]);
Expand Down
3 changes: 3 additions & 0 deletions src/prompts/append-system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const APPEND_PROMPT_AGENT_IDS: readonly RuntimeAgentId[] = [
"claude",
"codex",
"cline",
"cursor",
"droid",
"kiro",
"gemini",
Expand Down Expand Up @@ -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: `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":
Expand Down
29 changes: 21 additions & 8 deletions src/terminal/agent-registry.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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,
};
}
Expand Down
176 changes: 175 additions & 1 deletion src/terminal/agent-session-adapters.ts
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";
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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];
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Prompt To Fix With AI
This 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;
Expand Down Expand Up @@ -1430,6 +1603,7 @@ const clineAdapter: AgentSessionAdapter = {
const ADAPTERS: Record<RuntimeAgentId, AgentSessionAdapter> = {
claude: claudeAdapter,
codex: codexAdapter,
cursor: cursorAdapter,
gemini: geminiAdapter,
opencode: opencodeAdapter,
droid: droidAdapter,
Expand Down
Loading