diff --git a/README.md b/README.md index 2c922ec0..25b1a89b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. -- **Your AI, Your Choice** - Claude Code, Codex, Gemini, OpenCode—different models, one unified workflow. +- **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. - **Terminal Anywhere** - Run commands from your phone or browser, directly connected to the working machine. - **Voice Control** - Talk to your AI agent hands-free using the built-in voice assistant. @@ -36,6 +36,7 @@ For self-hosted options (Cloudflare Tunnel, Tailscale), see [Installation](docs/ - [App](docs/guide/pwa.md) - [How it Works](docs/guide/how-it-works.md) +- [Cursor Agent](docs/guide/cursor.md) - [Voice Assistant](docs/guide/voice-assistant.md) - [Why HAPI](docs/guide/why-hapi.md) - [FAQ](docs/guide/faq.md) diff --git a/bun.lock b/bun.lock index f26d6381..5795ecf2 100644 --- a/bun.lock +++ b/bun.lock @@ -882,6 +882,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.15.4", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-ynalKhuZ9TTGYlcPBeTop4gA0cw6JRYMVsj7XUJyr30yw+/CMmR8LB+0MEykH4r63aZjbJlVXi4VqHfO2h17qQ=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.15.4", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-sNkw5OPToXX1tfRlIzFLTiK5P+OxD78LktnEZdsoqgyS2Z1oU/1SQ2+82B6jJ3+G4xew3PtzZq2s/y3LKkIMZg=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/README.md b/cli/README.md index 006fb22e..cd6c2ddd 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,11 +1,12 @@ # hapi CLI -Run Claude Code, Codex, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. +Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your terminal and control them remotely through the hapi hub. ## What it does - Starts Claude Code sessions and registers them with hapi-hub. - Starts Codex mode for OpenAI-based sessions. +- Starts Cursor Agent mode for Cursor CLI sessions. - Starts Gemini mode via ACP (Anthropic Code Plugins). - Starts OpenCode mode via ACP and its plugin hook system. - Provides an MCP stdio bridge for external tools. @@ -26,6 +27,9 @@ Run Claude Code, Codex, Gemini, or OpenCode sessions from your terminal and cont - `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. - `hapi codex resume ` - Resume existing Codex session. +- `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. + Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. + Local and remote modes supported; remote uses `agent -p` with stream-json. - `hapi gemini` - Start Gemini mode via ACP. See `src/agent/runners/runAgentSession.ts`. Note: Gemini runs in remote mode only; it waits for messages from the hub UI/Telegram. - `hapi opencode` - Start OpenCode mode via ACP. See `src/opencode/runOpencode.ts`. @@ -103,6 +107,7 @@ Data is stored in `~/.hapi/` (or `$HAPI_HOME`): ## Requirements - Claude CLI installed and logged in (`claude` on PATH). +- Cursor Agent CLI installed (`agent` on PATH) for `hapi cursor`. Install: `curl https://cursor.com/install -fsS | bash` (macOS/Linux), `irm 'https://cursor.com/install?win32=true' | iex` (Windows). - OpenCode CLI installed (`opencode` on PATH). - Bun for building from source. @@ -127,6 +132,7 @@ bun run build:single-exe - `src/api/` - Bot communication (Socket.IO + REST). - `src/claude/` - Claude Code integration. - `src/codex/` - Codex mode integration. +- `src/cursor/` - Cursor Agent integration. - `src/agent/` - Multi-agent support (Gemini via ACP). - `src/opencode/` - OpenCode ACP + hook integration. - `src/runner/` - Background service. diff --git a/cli/src/commands/claude.ts b/cli/src/commands/claude.ts index 951a6ffa..21ac75f0 100644 --- a/cli/src/commands/claude.ts +++ b/cli/src/commands/claude.ts @@ -71,6 +71,7 @@ ${chalk.bold('Usage:')} hapi [options] Start Claude with Telegram control (direct-connect) hapi auth Manage authentication hapi codex Start Codex mode + hapi cursor Start Cursor Agent mode hapi gemini Start Gemini ACP mode hapi opencode Start OpenCode ACP mode hapi mcp Start MCP stdio bridge diff --git a/cli/src/commands/cursor.ts b/cli/src/commands/cursor.ts new file mode 100644 index 00000000..c29ce3cc --- /dev/null +++ b/cli/src/commands/cursor.ts @@ -0,0 +1,91 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import type { CursorPermissionMode } from '@hapi/protocol/types' + +export const cursorCommand: CommandDefinition = { + name: 'cursor', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const { runCursor } = await import('@/cursor/runCursor') + + const options: { + startedBy?: 'runner' | 'terminal' + cursorArgs?: string[] + permissionMode?: CursorPermissionMode + resumeSessionId?: string + model?: string + } = {} + const unknownArgs: string[] = [] + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (i === 0 && arg === 'resume') { + const candidate = commandArgs[i + 1] + if (!candidate || candidate.startsWith('-')) { + throw new Error('resume requires a chat id') + } + options.resumeSessionId = candidate + i += 1 + continue + } + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--yolo' || arg === '--force') { + options.permissionMode = 'yolo' + } else if (arg === '--mode') { + const mode = commandArgs[++i] + if (!mode) { + throw new Error('Missing --mode value') + } + if (mode === 'plan' || mode === 'ask') { + options.permissionMode = mode + } + } else if (arg === '--plan') { + options.permissionMode = 'plan' + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } else if (arg === '--resume') { + const chatId = commandArgs[i + 1] + if (chatId && !chatId.startsWith('-')) { + options.resumeSessionId = chatId + i += 1 + } else { + unknownArgs.push(arg) + } + } else if (arg === '--continue') { + unknownArgs.push(arg) + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value !== 'local' && value !== 'remote') { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + continue + } else { + unknownArgs.push(arg) + } + } + if (unknownArgs.length > 0) { + options.cursorArgs = unknownArgs + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + await runCursor(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 32a27bb8..416d9d59 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -1,6 +1,7 @@ import { authCommand } from './auth' import { claudeCommand } from './claude' import { codexCommand } from './codex' +import { cursorCommand } from './cursor' import { connectCommand } from './connect' import { runnerCommand } from './runner' import { doctorCommand } from './doctor' @@ -16,6 +17,7 @@ const COMMANDS: CommandDefinition[] = [ authCommand, connectCommand, codexCommand, + cursorCommand, geminiCommand, opencodeCommand, mcpCommand, diff --git a/cli/src/cursor/cursorLocal.ts b/cli/src/cursor/cursorLocal.ts new file mode 100644 index 00000000..129d0b0c --- /dev/null +++ b/cli/src/cursor/cursorLocal.ts @@ -0,0 +1,83 @@ +import { logger } from '@/ui/logger'; +import { restoreTerminalState } from '@/ui/terminalState'; +import { spawnWithAbort } from '@/utils/spawnWithAbort'; + +/** + * Filter out 'resume' subcommand which is managed internally by hapi. + * Cursor CLI format: `agent resume` or `agent resume ` + */ +export function filterResumeSubcommand(args: string[]): string[] { + if (args.length === 0 || args[0] !== 'resume') { + return args; + } + + if (args.length > 1 && !args[1].startsWith('-')) { + logger.debug(`[CursorLocal] Filtered 'resume ${args[1]}' - session managed by hapi`); + return args.slice(2); + } + + logger.debug(`[CursorLocal] Filtered 'resume' - session managed by hapi`); + return args.slice(1); +} + +export async function cursorLocal(opts: { + abort: AbortSignal; + chatId: string | null; + path: string; + model?: string; + mode?: 'plan' | 'ask'; + yolo?: boolean; + onChatFound?: (chatId: string) => void; + cursorArgs?: string[]; +}): Promise { + const args: string[] = []; + + if (opts.chatId) { + args.push('--resume', opts.chatId); + opts.onChatFound?.(opts.chatId); + } + + if (opts.model) { + args.push('--model', opts.model); + } + + if (opts.mode) { + args.push('--mode', opts.mode); + } + + if (opts.yolo) { + args.push('--yolo'); + } + + if (opts.cursorArgs) { + const safeArgs = filterResumeSubcommand(opts.cursorArgs); + args.push(...safeArgs); + } + + logger.debug(`[CursorLocal] Spawning agent with args: ${JSON.stringify(args)}`); + + if (opts.abort.aborted) { + logger.debug('[CursorLocal] Abort already signaled before spawn; skipping launch'); + return; + } + + process.stdin.pause(); + try { + await spawnWithAbort({ + command: 'agent', + args, + cwd: opts.path, + env: process.env, + signal: opts.abort, + logLabel: 'CursorLocal', + spawnName: 'agent', + installHint: 'Cursor Agent CLI (curl https://cursor.com/install -fsS | bash)', + includeCause: true, + logExit: true, + shell: process.platform === 'win32' + }); + } finally { + process.stdin.resume(); + restoreTerminalState(); + } +} diff --git a/cli/src/cursor/cursorLocalLauncher.ts b/cli/src/cursor/cursorLocalLauncher.ts new file mode 100644 index 00000000..7998f6e6 --- /dev/null +++ b/cli/src/cursor/cursorLocalLauncher.ts @@ -0,0 +1,57 @@ +import { logger } from '@/ui/logger'; +import { cursorLocal } from './cursorLocal'; +import { CursorSession } from './session'; +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; + +function permissionModeToCursorArgs(mode?: string): { mode?: 'plan' | 'ask'; yolo?: boolean } { + if (mode === 'plan') { + return { mode: 'plan' }; + } + if (mode === 'ask') { + return { mode: 'ask' }; + } + if (mode === 'yolo') { + return { yolo: true }; + } + return {}; +} + +export async function cursorLocalLauncher(session: CursorSession): Promise<'switch' | 'exit'> { + const resumeChatId = session.sessionId; + if (resumeChatId) { + session.onSessionFound(resumeChatId); + } + const { mode, yolo } = permissionModeToCursorArgs(session.getPermissionMode() as string); + + const launcher = new BaseLocalLauncher({ + label: 'cursor-local', + failureLabel: 'Local Cursor Agent process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + await cursorLocal({ + path: session.path, + chatId: resumeChatId, + abort: abortSignal, + cursorArgs: session.cursorArgs, + model: session.model, + mode, + yolo, + onChatFound: (chatId) => session.onSessionFound(chatId) + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + }, + abortLogMessage: 'doAbort', + switchLogMessage: 'doSwitch' + }); + + const result = await launcher.run(); + return result === 'exit' ? 'exit' : 'switch'; +} diff --git a/cli/src/cursor/cursorRemoteLauncher.ts b/cli/src/cursor/cursorRemoteLauncher.ts new file mode 100644 index 00000000..34279fd8 --- /dev/null +++ b/cli/src/cursor/cursorRemoteLauncher.ts @@ -0,0 +1,256 @@ +import React from 'react'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { logger } from '@/ui/logger'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import { OpencodeDisplay } from '@/ui/ink/OpencodeDisplay'; +import { + RemoteLauncherBase, + type RemoteLauncherDisplayContext, + type RemoteLauncherExitReason +} from '@/modules/common/remote/RemoteLauncherBase'; +import type { CursorSession } from './session'; +import type { CursorStreamEvent } from './utils/cursorEventConverter'; +import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorEventConverter'; + +function buildAgentArgs(opts: { + message: string; + cwd: string; + sessionId: string | null; + mode?: string; + model?: string; + yolo?: boolean; +}): string[] { + const args = ['-p', opts.message, '--output-format', 'stream-json', '--trust', '--workspace', opts.cwd]; + + if (opts.sessionId) { + args.push('--resume', opts.sessionId); + } + if (opts.mode && (opts.mode === 'plan' || opts.mode === 'ask')) { + args.push('--mode', opts.mode); + } + if (opts.model) { + args.push('--model', opts.model); + } + if (opts.yolo) { + args.push('--yolo'); + } + + return args; +} + +function permissionModeToAgentArgs(mode?: string): { mode?: string; yolo?: boolean } { + if (mode === 'plan') return { mode: 'plan' }; + if (mode === 'ask') return { mode: 'ask' }; + if (mode === 'yolo') return { yolo: true }; + return {}; +} + +class CursorRemoteLauncher extends RemoteLauncherBase { + private readonly session: CursorSession; + private abortController = new AbortController(); + private displayPermissionMode: string | null = null; + + constructor(session: CursorSession) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(OpencodeDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + let cursorSessionId: string | null = session.sessionId; + + while (!this.shouldExit) { + const waitSignal = this.abortController.signal; + const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal); + if (!batch) { + if (waitSignal.aborted && !this.shouldExit) { + continue; + } + break; + } + + const { message, mode } = batch; + const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string); + this.applyDisplayMode(mode.permissionMode as string); + messageBuffer.addMessage(message, 'user'); + + const args = buildAgentArgs({ + message, + cwd: session.path, + sessionId: cursorSessionId, + mode: agentMode, + model: session.model, + yolo + }); + + logger.debug(`[cursor-remote] Spawning agent with args: ${args.join(' ')}`); + + session.onThinkingChange(true); + + try { + const exitCode = await this.runAgentProcess(args, session.path, (event) => { + if (event.type === 'system' && event.subtype === 'init' && event.session_id) { + cursorSessionId = event.session_id; + session.onSessionFound(event.session_id); + } else if (event.type === 'thinking') { + if (event.subtype === 'completed') { + // keep thinking until we get assistant/result + } + } else if (event.type === 'assistant' || event.type === 'tool_call' || event.type === 'result') { + const agentMsg = convertCursorEventToAgentMessage(event); + if (agentMsg) { + const codexMsg = convertAgentMessage(agentMsg); + if (codexMsg) { + session.sendCodexMessage(codexMsg); + } + switch (agentMsg.type) { + case 'text': + messageBuffer.addMessage(agentMsg.text, 'assistant'); + break; + case 'tool_call': + messageBuffer.addMessage(`Tool: ${agentMsg.name}`, 'tool'); + break; + case 'tool_result': + messageBuffer.addMessage('Tool result', 'result'); + break; + case 'turn_complete': + break; + default: + break; + } + } + } + }); + + if (exitCode !== 0 && exitCode !== null) { + logger.debug(`[cursor-remote] Agent exited with code ${exitCode}`); + messageBuffer.addMessage(`Agent exited with code ${exitCode}`, 'status'); + } + } catch (error) { + logger.warn('[cursor-remote] Agent run failed', error); + const errMsg = error instanceof Error ? error.message : String(error); + session.sendSessionEvent({ type: 'message', message: `Cursor Agent failed: ${errMsg}` }); + messageBuffer.addMessage(`Cursor Agent failed: ${errMsg}`, 'status'); + } finally { + session.onThinkingChange(false); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + private runAgentProcess( + args: string[], + cwd: string, + onEvent: (event: ReturnType & object) => void + ): Promise { + return new Promise((resolve, reject) => { + const child = spawn('agent', args, { + cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32' + }); + + const abortHandler = () => { + try { + child.kill('SIGTERM'); + } catch { + // ignore + } + resolve(null); + }; + this.abortController.signal.addEventListener('abort', abortHandler); + + const cleanup = () => { + this.abortController.signal.removeEventListener('abort', abortHandler); + }; + + child.on('error', (err) => { + cleanup(); + reject(err); + }); + + child.on('exit', (code, signal) => { + cleanup(); + resolve(code); + }); + + const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); + rl.on('line', (line) => { + const event = parseCursorEvent(line); + if (event) { + onEvent(event); + } + }); + + child.stderr?.on('data', (chunk) => { + const text = chunk.toString(); + if (text.trim()) { + logger.debug('[cursor-remote] agent stderr:', text.trim()); + } + }); + }); + } + + private applyDisplayMode(permissionMode: string | undefined): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + this.abortController.abort(); + } + + private async handleAbort(): Promise { + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +export async function cursorRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> { + const launcher = new CursorRemoteLauncher(session); + return launcher.launch(); +} diff --git a/cli/src/cursor/loop.ts b/cli/src/cursor/loop.ts new file mode 100644 index 00000000..2a186b4a --- /dev/null +++ b/cli/src/cursor/loop.ts @@ -0,0 +1,60 @@ +import { logger } from '@/ui/logger'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { runLocalRemoteSession } from '@/agent/loopBase'; +import { CursorSession } from './session'; +import { cursorLocalLauncher } from './cursorLocalLauncher'; +import { cursorRemoteLauncher } from './cursorRemoteLauncher'; +import { ApiClient, ApiSessionClient } from '@/lib'; +import type { CursorPermissionMode } from '@hapi/protocol/types'; + +export type PermissionMode = CursorPermissionMode; + +export interface EnhancedMode { + permissionMode: PermissionMode; + model?: string; +} + +interface LoopOptions { + path: string; + startingMode?: 'local' | 'remote'; + startedBy?: 'runner' | 'terminal'; + onModeChange: (mode: 'local' | 'remote') => void; + messageQueue: MessageQueue2; + session: ApiSessionClient; + api: ApiClient; + cursorArgs?: string[]; + permissionMode?: PermissionMode; + resumeSessionId?: string; + model?: string; + onSessionReady?: (session: CursorSession) => void; +} + +export async function loop(opts: LoopOptions): Promise { + const logPath = logger.getLogPath(); + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; + const session = new CursorSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: startingMode, + startedBy, + startingMode, + cursorArgs: opts.cursorArgs, + model: opts.model, + permissionMode: opts.permissionMode ?? 'default' + }); + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'cursor-loop', + runLocal: cursorLocalLauncher, + runRemote: cursorRemoteLauncher, + onSessionReady: opts.onSessionReady + }); +} diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts new file mode 100644 index 00000000..124c7b4f --- /dev/null +++ b/cli/src/cursor/runCursor.ts @@ -0,0 +1,138 @@ +import { logger } from '@/ui/logger'; +import { loop, type EnhancedMode, type PermissionMode } from './loop'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import type { AgentState } from '@/api/types'; +import type { CursorSession } from './session'; +import { bootstrapSession } from '@/agent/sessionFactory'; +import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; + +const formatFailureReason = (message: string): string => { + const maxLength = 200; + if (message.length <= maxLength) { + return message; + } + return `${message.slice(0, maxLength)}...`; +}; + +export async function runCursor(opts: { + startedBy?: 'runner' | 'terminal'; + cursorArgs?: string[]; + permissionMode?: PermissionMode; + resumeSessionId?: string; + model?: string; +}): Promise { + const workingDirectory = process.cwd(); + const startedBy = opts.startedBy ?? 'terminal'; + + logger.debug(`[cursor] Starting with options: startedBy=${startedBy}`); + + const state: AgentState = { + controlledByUser: false + }; + const { api, session } = await bootstrapSession({ + flavor: 'cursor', + startedBy, + workingDirectory, + agentState: state + }); + + const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; + + setControlledByUser(session, startingMode); + + const messageQueue = new MessageQueue2((mode) => + hashObject({ + permissionMode: mode.permissionMode, + model: mode.model + }) + ); + + const sessionWrapperRef: { current: CursorSession | null } = { current: null }; + + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + const currentModel = opts.model; + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'cursor', + stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive() + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + + const syncSessionMode = () => { + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + logger.debug(`[cursor] Synced session permission mode: ${currentPermissionMode}`); + }; + + session.onUserMessage((message) => { + const enhancedMode: EnhancedMode = { + permissionMode: currentPermissionMode ?? 'default', + model: currentModel + }; + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + messageQueue.push(formattedText, enhancedMode); + }); + + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'cursor')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: unknown }; + + if (config.permissionMode !== undefined) { + currentPermissionMode = resolvePermissionMode(config.permissionMode); + } + + syncSessionMode(); + return { applied: { permissionMode: currentPermissionMode } }; + }); + + try { + await loop({ + path: workingDirectory, + startingMode, + messageQueue, + api, + session, + cursorArgs: opts.cursorArgs, + startedBy, + permissionMode: currentPermissionMode, + resumeSessionId: opts.resumeSessionId, + model: opts.model, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionWrapperRef.current = instance; + syncSessionMode(); + } + }); + } catch (error) { + lifecycle.markCrash(error); + logger.debug('[cursor] Loop error:', error); + } finally { + const localFailure = sessionWrapperRef.current?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(`Local launch failed: ${formatFailureReason(localFailure.message)}`); + } + await lifecycle.cleanupAndExit(); + } +} diff --git a/cli/src/cursor/session.ts b/cli/src/cursor/session.ts new file mode 100644 index 00000000..f67e6686 --- /dev/null +++ b/cli/src/cursor/session.ts @@ -0,0 +1,78 @@ +import { ApiClient, ApiSessionClient } from '@/lib'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { AgentSessionBase } from '@/agent/sessionBase'; +import type { EnhancedMode, PermissionMode } from './loop'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; + +export class CursorSession extends AgentSessionBase { + readonly cursorArgs?: string[]; + readonly model?: string; + readonly startedBy: 'runner' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + sessionId: string | null; + messageQueue: MessageQueue2; + onModeChange: (mode: 'local' | 'remote') => void; + mode?: 'local' | 'remote'; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + cursorArgs?: string[]; + model?: string; + permissionMode?: PermissionMode; + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'CursorSession', + sessionIdLabel: 'Cursor', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + cursorSessionId: sessionId + }), + permissionMode: opts.permissionMode + }); + + this.cursorArgs = opts.cursorArgs; + this.model = opts.model; + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; + } + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + + sendCodexMessage = (message: unknown): void => { + this.client.sendCodexMessage(message); + }; + + sendUserMessage = (text: string): void => { + this.client.sendUserMessage(text); + }; + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event); + }; +} diff --git a/cli/src/cursor/utils/cursorEventConverter.test.ts b/cli/src/cursor/utils/cursorEventConverter.test.ts new file mode 100644 index 00000000..a9f6438a --- /dev/null +++ b/cli/src/cursor/utils/cursorEventConverter.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + parseCursorEvent, + convertCursorEventToAgentMessage, + type CursorStreamEvent +} from './cursorEventConverter'; + +describe('cursorEventConverter', () => { + describe('parseCursorEvent', () => { + it('parses system init event', () => { + const line = + '{"type":"system","subtype":"init","apiKeySource":"login","cwd":"D:\\\\projects\\\\hapi","session_id":"cec26d70-d2d5-48ac-a88b-9e820eb201cf","timestamp_ms":1772422778942}'; + const event = parseCursorEvent(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('system'); + if (event && event.type === 'system') { + expect(event.subtype).toBe('init'); + expect(event.session_id).toBe('cec26d70-d2d5-48ac-a88b-9e820eb201cf'); + } + }); + + it('parses assistant event', () => { + const line = + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"\\n你好。"}]},"session_id":"cec26d70-d2d5-48ac-a88b-9e820eb201cf"}'; + const event = parseCursorEvent(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('assistant'); + }); + + it('parses result event', () => { + const line = + '{"type":"result","subtype":"success","duration_ms":12456,"is_error":false,"result":"\\n你好。","session_id":"cec26d70-d2d5-48ac-a88b-9e820eb201cf"}'; + const event = parseCursorEvent(line); + expect(event).not.toBeNull(); + expect(event?.type).toBe('result'); + }); + + it('returns null for non-JSON lines', () => { + expect(parseCursorEvent('')).toBeNull(); + expect(parseCursorEvent(' ')).toBeNull(); + expect(parseCursorEvent('正在写入 Web 请求')).toBeNull(); + }); + }); + + describe('convertCursorEventToAgentMessage', () => { + it('converts assistant to text message', () => { + const event = { + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }, + session_id: 's1' + } as CursorStreamEvent; + const msg = convertCursorEventToAgentMessage(event); + expect(msg).toEqual({ type: 'text', text: 'Hello' }); + }); + + it('converts result to turn_complete', () => { + const event = { type: 'result', subtype: 'success', session_id: 's1' } as CursorStreamEvent; + const msg = convertCursorEventToAgentMessage(event); + expect(msg).toEqual({ type: 'turn_complete', stopReason: 'success' }); + }); + }); +}); diff --git a/cli/src/cursor/utils/cursorEventConverter.ts b/cli/src/cursor/utils/cursorEventConverter.ts new file mode 100644 index 00000000..45fc752b --- /dev/null +++ b/cli/src/cursor/utils/cursorEventConverter.ts @@ -0,0 +1,126 @@ +/** + * Converts Cursor Agent stream-json events to HAPI AgentMessage format. + * Cursor emits NDJSON: system/init, thinking, assistant, tool_call, result. + */ + +import type { AgentMessage } from '@/agent/types'; + +export type CursorStreamEvent = + | { type: 'system'; subtype: 'init'; session_id: string; cwd?: string; model?: string } + | { type: 'thinking'; subtype: 'delta' | 'completed'; text?: string; session_id: string } + | { + type: 'user'; + message: { role: string; content: Array<{ type: string; text: string }> }; + session_id: string; + } + | { + type: 'assistant'; + message: { role: string; content: Array<{ type: string; text: string }> }; + session_id: string; + } + | { + type: 'tool_call'; + subtype: 'started' | 'completed'; + call_id: string; + tool_call: Record; + session_id: string; + } + | { + type: 'result'; + subtype: 'success'; + session_id: string; + result?: string; + is_error?: boolean; + }; + +export function parseCursorEvent(line: string): CursorStreamEvent | null { + const trimmed = line.trim(); + if (!trimmed || !trimmed.startsWith('{')) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && 'type' in parsed) { + return parsed as CursorStreamEvent; + } + } catch { + // ignore non-JSON lines (e.g. stderr progress) + } + return null; +} + +function extractToolName(toolCall: Record): string { + if (toolCall.readToolCall) return 'read_file'; + if (toolCall.writeToolCall) return 'write_file'; + if (toolCall.function && typeof toolCall.function === 'object') { + const fn = toolCall.function as Record; + return typeof fn.name === 'string' ? fn.name : 'unknown'; + } + return 'unknown'; +} + +function extractToolInput(toolCall: Record): unknown { + if (toolCall.readToolCall && typeof toolCall.readToolCall === 'object') { + const r = (toolCall.readToolCall as Record).args; + return r ?? {}; + } + if (toolCall.writeToolCall && typeof toolCall.writeToolCall === 'object') { + const w = (toolCall.writeToolCall as Record).args; + return w ?? {}; + } + if (toolCall.function && typeof toolCall.function === 'object') { + const fn = toolCall.function as Record; + return { arguments: fn.arguments }; + } + return {}; +} + +function extractToolResult(toolCall: Record): unknown { + if (toolCall.readToolCall && typeof toolCall.readToolCall === 'object') { + const r = toolCall.readToolCall as Record; + return r.result ?? r; + } + if (toolCall.writeToolCall && typeof toolCall.writeToolCall === 'object') { + const w = toolCall.writeToolCall as Record; + return w.result ?? w; + } + return {}; +} + +export function convertCursorEventToAgentMessage(event: CursorStreamEvent): AgentMessage | null { + switch (event.type) { + case 'assistant': { + const text = event.message?.content + ?.filter((c): c is { type: string; text: string } => c.type === 'text') + .map((c) => c.text) + .join('') ?? ''; + if (!text) return null; + return { type: 'text', text }; + } + case 'tool_call': { + const toolCall = event.tool_call as Record; + const name = extractToolName(toolCall); + const input = extractToolInput(toolCall); + if (event.subtype === 'started') { + return { + type: 'tool_call', + id: event.call_id, + name, + input, + status: 'in_progress' + }; + } + const result = extractToolResult(toolCall); + return { + type: 'tool_result', + id: event.call_id, + output: result, + status: 'completed' + }; + } + case 'result': + return { type: 'turn_complete', stopReason: 'success' }; + default: + return null; + } +} diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 2c6f7294..15a3602c 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -4,7 +4,7 @@ export interface SpawnSessionOptions { sessionId?: string resumeSessionId?: string approvedNewDirectoryCreation?: boolean - agent?: 'claude' | 'codex' | 'gemini' | 'opencode' + agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' model?: string yolo?: boolean token?: string diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index b6494077..3ae5ac44 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -325,15 +325,19 @@ export async function startRunner(): Promise { // Construct arguments for the CLI const agentCommand = agent === 'codex' ? 'codex' - : agent === 'gemini' - ? 'gemini' - : agent === 'opencode' - ? 'opencode' - : 'claude'; + : agent === 'cursor' + ? 'cursor' + : agent === 'gemini' + ? 'gemini' + : agent === 'opencode' + ? 'opencode' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); + } else if (agent === 'cursor') { + args.push('--resume', options.resumeSessionId); } else { args.push('--resume', options.resumeSessionId); } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 15562e64..655a6b70 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -22,6 +22,7 @@ export default defineConfig({ { text: 'Installation', link: '/guide/installation' }, { text: 'PWA', link: '/guide/pwa' }, { text: 'How it Works', link: '/guide/how-it-works' }, + { text: 'Cursor Agent', link: '/guide/cursor' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'FAQ', link: '/guide/faq' } diff --git a/docs/guide/cursor.md b/docs/guide/cursor.md new file mode 100644 index 00000000..b69c67ea --- /dev/null +++ b/docs/guide/cursor.md @@ -0,0 +1,62 @@ +# Cursor Agent + +HAPI supports [Cursor Agent CLI](https://cursor.com/docs/cli/using) for running Cursor's AI coding agent with remote control via web and phone. + +## Prerequisites + +Install Cursor Agent CLI: + +- **macOS/Linux:** `curl https://cursor.com/install -fsS | bash` +- **Windows:** `irm 'https://cursor.com/install?win32=true' | iex` + +Verify installation: + +```bash +agent --version +``` + +## Usage + +```bash +hapi cursor # Start Cursor Agent session +hapi cursor resume # Resume a specific chat +hapi cursor --continue # Resume the most recent chat +hapi cursor --mode plan # Start in Plan mode +hapi cursor --mode ask # Start in Ask mode +hapi cursor --yolo # Bypass approval prompts (--force) +hapi cursor --model # Specify model +``` + +## Permission Modes + +| Mode | Description | +|------|-------------| +| `default` | Standard agent behavior | +| `plan` | Plan mode - design approach before coding | +| `ask` | Ask mode - explore code without edits | +| `yolo` | Bypass approval prompts | + +Set mode via `--mode` flag or change from the web UI during a session. + +## Modes + +- **Local mode** - Run `hapi cursor` from terminal. Full interactive experience. +- **Remote mode** - Spawn from web/phone when no terminal. Uses `agent -p` with `--output-format stream-json` and `--trust`. Each user message spawns one agent process; session continues via `--resume`. + +## Limitations + +- **Tool approval** - In remote mode, `--trust` is used; tools run without per-request approval. Use `--yolo` for full bypass. +- **Session resume** - Pass `--resume ` or `--continue` to resume. Use `agent ls` to list previous chats and get chat IDs. + +## Integration + +Once running, your Cursor session appears in the HAPI web app and Telegram Mini App. You can: + +- Monitor session activity +- Approve permissions from your phone +- Send messages when in local mode (messages queue for when you switch) + +## Related + +- [Cursor CLI Documentation](https://cursor.com/docs/cli/using) +- [How it Works](./how-it-works.md) - Architecture and data flow diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 7cac778b..9eb8af2d 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -18,6 +18,7 @@ Yes, HAPI is open source and free to use under the AGPL-3.0-only license. - **Claude Code** (recommended) - **OpenAI Codex** +- **Cursor Agent** - **Google Gemini** - **OpenCode** @@ -156,6 +157,19 @@ npm install -g @anthropic-ai/claude-code export HAPI_CLAUDE_PATH=/path/to/claude ``` +### Cursor Agent not found + +Install Cursor Agent CLI: +```bash +# macOS/Linux +curl https://cursor.com/install -fsS | bash + +# Windows (PowerShell) +irm 'https://cursor.com/install?win32=true' | iex +``` + +Ensure `agent` is on your PATH. + ### How do I run diagnostics? ```bash diff --git a/docs/guide/how-it-works.md b/docs/guide/how-it-works.md index e49c11e5..4fedcb90 100644 --- a/docs/guide/how-it-works.md +++ b/docs/guide/how-it-works.md @@ -47,7 +47,7 @@ HAPI consists of three interconnected components that work together to provide r ### HAPI CLI -The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCode). It: +The CLI is a wrapper around AI coding agents (Claude Code, Codex, Cursor Agent, Gemini, OpenCode). It: - Starts and manages coding sessions - Registers sessions with the HAPI hub @@ -57,9 +57,10 @@ The CLI is a wrapper around AI coding agents (Claude Code, Codex, Gemini, OpenCo **Key Commands:** ```bash hapi # Start Claude Code session -hapi codex # Start OpenAI Codex session -hapi gemini # Start Google Gemini session -hapi opencode # Start OpenCode session +hapi codex # Start OpenAI Codex session +hapi cursor # Start Cursor Agent session +hapi gemini # Start Google Gemini session +hapi opencode # Start OpenCode session hapi runner start # Run background service for remote session spawning ``` diff --git a/docs/guide/installation.md b/docs/guide/installation.md index a15b7fa5..4fe2b6b5 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -4,7 +4,7 @@ Install the HAPI CLI and set up the hub. ## Prerequisites -- Claude Code, OpenAI Codex CLI, Google Gemini CLI, or OpenCode CLI installed +- Claude Code, OpenAI Codex CLI, Cursor Agent CLI, Google Gemini CLI, or OpenCode CLI installed Verify your CLI is installed: @@ -15,6 +15,9 @@ claude --version # For OpenAI Codex CLI codex --version +# For Cursor Agent CLI +agent --version + # For Google Gemini CLI gemini --version @@ -28,7 +31,7 @@ HAPI has three components: | Component | Role | Required | |-----------|------|----------| -| **CLI** | Wraps AI agents (Claude/Codex/Gemini/OpenCode), runs sessions | Yes | +| **CLI** | Wraps AI agents (Claude/Codex/Cursor/Gemini/OpenCode), runs sessions | Yes | | **Hub** | Central coordinator: persistence, real-time sync, remote access | Yes | | **Runner** | Background service for remote session spawning | Optional | diff --git a/hub/src/notifications/sessionInfo.ts b/hub/src/notifications/sessionInfo.ts index 7094f208..f9d3ddeb 100644 --- a/hub/src/notifications/sessionInfo.ts +++ b/hub/src/notifications/sessionInfo.ts @@ -14,6 +14,7 @@ export function getAgentName(session: Session): string { const flavor = session.metadata?.flavor if (flavor === 'claude') return 'Claude' if (flavor === 'codex') return 'Codex' + if (flavor === 'cursor') return 'Cursor' if (flavor === 'gemini') return 'Gemini' if (flavor === 'opencode') return 'OpenCode' return 'Agent' diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 84a3b05e..85774dcd 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -106,7 +106,7 @@ export class RpcGateway { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', + agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 1ab46e65..23ae6376 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -300,7 +300,7 @@ export class SyncEngine { async spawnSession( machineId: string, directory: string, - agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', + agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', @@ -330,7 +330,7 @@ export class SyncEngine { return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } } - const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' + const flavor = metadata.flavor === 'codex' || metadata.flavor === 'gemini' || metadata.flavor === 'opencode' || metadata.flavor === 'cursor' ? metadata.flavor : 'claude' const resumeToken = flavor === 'codex' @@ -339,7 +339,9 @@ export class SyncEngine { ? metadata.geminiSessionId : flavor === 'opencode' ? metadata.opencodeSessionId - : metadata.claudeSessionId + : flavor === 'cursor' + ? metadata.cursorSessionId + : metadata.claudeSessionId if (!resumeToken) { return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 5749d0b8..626c5f95 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -6,7 +6,7 @@ import { requireMachine } from './guards' const spawnBodySchema = z.object({ directory: z.string().min(1), - agent: z.enum(['claude', 'codex', 'gemini', 'opencode']).optional(), + agent: z.enum(['claude', 'codex', 'cursor', 'gemini', 'opencode']).optional(), model: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), diff --git a/shared/src/modes.ts b/shared/src/modes.ts index a07f317b..d870bb38 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -10,11 +10,15 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] +export const CURSOR_PERMISSION_MODES = ['default', 'plan', 'ask', 'yolo'] as const +export type CursorPermissionMode = typeof CURSOR_PERMISSION_MODES[number] + export const PERMISSION_MODES = [ 'default', 'acceptEdits', 'bypassPermissions', 'plan', + 'ask', 'read-only', 'safe-yolo', 'yolo' @@ -24,12 +28,13 @@ export type PermissionMode = typeof PERMISSION_MODES[number] export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const export type ModelMode = typeof MODEL_MODES[number] -export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' +export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor' export const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', + ask: 'Ask Mode', bypassPermissions: 'Yolo', 'read-only': 'Read Only', 'safe-yolo': 'Safe Yolo', @@ -42,6 +47,7 @@ export const PERMISSION_MODE_TONES: Record = default: 'neutral', acceptEdits: 'warning', plan: 'info', + ask: 'info', bypassPermissions: 'danger', 'read-only': 'warning', 'safe-yolo': 'warning', @@ -78,6 +84,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'opencode') { return OPENCODE_PERMISSION_MODES } + if (flavor === 'cursor') { + return CURSOR_PERMISSION_MODES + } return CLAUDE_PERMISSION_MODES } @@ -94,7 +103,7 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: } export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { - if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode') { + if (flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' || flavor === 'cursor') { return [] } return MODEL_MODES diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 985a2cc6..73402684 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -31,6 +31,7 @@ export const MetadataSchema = z.object({ codexSessionId: z.string().optional(), geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), + cursorSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), diff --git a/shared/src/types.ts b/shared/src/types.ts index 1a885f1a..665c46bb 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -17,6 +17,7 @@ export type { AgentFlavor, ClaudePermissionMode, CodexPermissionMode, + CursorPermissionMode, GeminiPermissionMode, OpencodePermissionMode, ModelMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 347e78f1..2d1dbfd2 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -372,7 +372,7 @@ export class ApiClient { async spawnSession( machineId: string, directory: string, - agent?: 'claude' | 'codex' | 'gemini' | 'opencode', + agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode', model?: string, yolo?: boolean, sessionType?: 'simple' | 'worktree', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 28f4180c..1ab88f29 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { usePlatform } from '@/hooks/usePlatform' import { usePWAInstall } from '@/hooks/usePWAInstall' -import { isCodexFamilyFlavor } from '@/lib/agentFlavorUtils' +import { isClaudeFlavor } from '@/lib/agentFlavorUtils' import { markSkillUsed } from '@/lib/recent-skills' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' @@ -324,7 +324,7 @@ export function HappyComposer(props: { useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && !isCodexFamilyFlavor(agentFlavor)) { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && isClaudeFlavor(agentFlavor)) { e.preventDefault() const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) const nextIndex = (currentIndex + 1) % MODEL_MODES.length @@ -398,7 +398,7 @@ export function HappyComposer(props: { }, [onModelModeChange, controlsDisabled, haptic]) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) - const showModelSettings = Boolean(onModelModeChange && !isCodexFamilyFlavor(agentFlavor)) + const showModelSettings = Boolean(onModelModeChange && isClaudeFlavor(agentFlavor)) const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) diff --git a/web/src/components/NewSession/AgentSelector.tsx b/web/src/components/NewSession/AgentSelector.tsx index 2e31c95f..80521afc 100644 --- a/web/src/components/NewSession/AgentSelector.tsx +++ b/web/src/components/NewSession/AgentSelector.tsx @@ -14,7 +14,7 @@ export function AgentSelector(props: { {t('newSession.agent')}
- {(['claude', 'codex', 'gemini', 'opencode'] as const).map((agentType) => ( + {(['claude', 'codex', 'cursor', 'gemini', 'opencode'] as const).map((agentType) => (