feat(cursor): add support for Cursor Agent CLI integration (#236)

* feat(cursor): add support for Cursor Agent CLI integration

- Introduced new command `hapi cursor` to start Cursor Agent sessions.
- Added functionality for resuming sessions and managing permission modes.
- Updated documentation to include Cursor Agent usage and installation instructions.
- Enhanced existing codebase to accommodate Cursor as a recognized agent flavor.
- Implemented local and remote session handling for Cursor Agent.

This update expands HAPI's capabilities by integrating support for the Cursor Agent, allowing users to leverage its features alongside existing agents.

* Remove TODO.md file as it is no longer needed following the integration of Cursor Agent CLI support. This cleanup helps streamline project documentation and reflects the completion of the associated tasks.

* feat(cursor): implement remote mode and fix --hapi-starting-mode

- Consume --hapi-starting-mode in cursor command (do not forward to agent)
- Implement cursorRemoteLauncher: spawn agent -p with stream-json, --trust
- Add cursorEventConverter for NDJSON parsing (system/assistant/tool_call/result)
- Multi-turn via --resume session_id
- Update docs: cursor supports both local and remote modes

Made-with: Cursor

* fix: type error

* fix(cursor): address PR review - model UI, sessionId metadata, duplicate flags

- HappyComposer: use isClaudeFlavor for model mode (cursor has no model modes)
- cursorLocalLauncher: call onSessionFound for resume so cursorSessionId in metadata
- cursorCommand: do not forward parsed flags to cursorArgs (avoid duplicates)

Made-with: Cursor
This commit is contained in:
Mao Mr
2026-03-03 10:02:47 +08:00
committed by GitHub
parent 72a23fc761
commit c9be2894ac
35 changed files with 1097 additions and 30 deletions
+2 -1
View File
@@ -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. - **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. - **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. - **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. - **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. - **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) - [App](docs/guide/pwa.md)
- [How it Works](docs/guide/how-it-works.md) - [How it Works](docs/guide/how-it-works.md)
- [Cursor Agent](docs/guide/cursor.md)
- [Voice Assistant](docs/guide/voice-assistant.md) - [Voice Assistant](docs/guide/voice-assistant.md)
- [Why HAPI](docs/guide/why-hapi.md) - [Why HAPI](docs/guide/why-hapi.md)
- [FAQ](docs/guide/faq.md) - [FAQ](docs/guide/faq.md)
+2
View File
@@ -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-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/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=="], "@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=="],
+7 -1
View File
@@ -1,11 +1,12 @@
# hapi CLI # 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 ## What it does
- Starts Claude Code sessions and registers them with hapi-hub. - Starts Claude Code sessions and registers them with hapi-hub.
- Starts Codex mode for OpenAI-based sessions. - Starts Codex mode for OpenAI-based sessions.
- Starts Cursor Agent mode for Cursor CLI sessions.
- Starts Gemini mode via ACP (Anthropic Code Plugins). - Starts Gemini mode via ACP (Anthropic Code Plugins).
- Starts OpenCode mode via ACP and its plugin hook system. - Starts OpenCode mode via ACP and its plugin hook system.
- Provides an MCP stdio bridge for external tools. - 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` - 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` - Start Codex mode. See `src/codex/runCodex.ts`.
- `hapi codex resume <sessionId>` - Resume existing Codex session. - `hapi codex resume <sessionId>` - Resume existing Codex session.
- `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`.
Supports `hapi cursor resume <chatId>`, `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`. - `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. 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`. - `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 ## Requirements
- Claude CLI installed and logged in (`claude` on PATH). - 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). - OpenCode CLI installed (`opencode` on PATH).
- Bun for building from source. - Bun for building from source.
@@ -127,6 +132,7 @@ bun run build:single-exe
- `src/api/` - Bot communication (Socket.IO + REST). - `src/api/` - Bot communication (Socket.IO + REST).
- `src/claude/` - Claude Code integration. - `src/claude/` - Claude Code integration.
- `src/codex/` - Codex mode integration. - `src/codex/` - Codex mode integration.
- `src/cursor/` - Cursor Agent integration.
- `src/agent/` - Multi-agent support (Gemini via ACP). - `src/agent/` - Multi-agent support (Gemini via ACP).
- `src/opencode/` - OpenCode ACP + hook integration. - `src/opencode/` - OpenCode ACP + hook integration.
- `src/runner/` - Background service. - `src/runner/` - Background service.
+1
View File
@@ -71,6 +71,7 @@ ${chalk.bold('Usage:')}
hapi [options] Start Claude with Telegram control (direct-connect) hapi [options] Start Claude with Telegram control (direct-connect)
hapi auth Manage authentication hapi auth Manage authentication
hapi codex Start Codex mode hapi codex Start Codex mode
hapi cursor Start Cursor Agent mode
hapi gemini Start Gemini ACP mode hapi gemini Start Gemini ACP mode
hapi opencode Start OpenCode ACP mode hapi opencode Start OpenCode ACP mode
hapi mcp Start MCP stdio bridge hapi mcp Start MCP stdio bridge
+91
View File
@@ -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)
}
}
}
+2
View File
@@ -1,6 +1,7 @@
import { authCommand } from './auth' import { authCommand } from './auth'
import { claudeCommand } from './claude' import { claudeCommand } from './claude'
import { codexCommand } from './codex' import { codexCommand } from './codex'
import { cursorCommand } from './cursor'
import { connectCommand } from './connect' import { connectCommand } from './connect'
import { runnerCommand } from './runner' import { runnerCommand } from './runner'
import { doctorCommand } from './doctor' import { doctorCommand } from './doctor'
@@ -16,6 +17,7 @@ const COMMANDS: CommandDefinition[] = [
authCommand, authCommand,
connectCommand, connectCommand,
codexCommand, codexCommand,
cursorCommand,
geminiCommand, geminiCommand,
opencodeCommand, opencodeCommand,
mcpCommand, mcpCommand,
+83
View File
@@ -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 <chatId>`
*/
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<void> {
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();
}
}
+57
View File
@@ -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';
}
+256
View File
@@ -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<RemoteLauncherExitReason> {
return this.start({
onExit: () => this.handleExitFromUi(),
onSwitchToLocal: () => this.handleSwitchFromUi()
});
}
protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement {
return React.createElement(OpencodeDisplay, context);
}
protected async runMainLoop(): Promise<void> {
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<typeof parseCursorEvent> & object) => void
): Promise<number | null> {
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<void> {
this.clearAbortHandlers(this.session.client.rpcHandlerManager);
this.abortController.abort();
}
private async handleAbort(): Promise<void> {
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<void> {
await this.requestExit('exit', () => this.handleAbort());
}
private async handleSwitchFromUi(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
private async handleSwitchRequest(): Promise<void> {
await this.requestExit('switch', () => this.handleAbort());
}
}
export async function cursorRemoteLauncher(session: CursorSession): Promise<'switch' | 'exit'> {
const launcher = new CursorRemoteLauncher(session);
return launcher.launch();
}
+60
View File
@@ -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<EnhancedMode>;
session: ApiSessionClient;
api: ApiClient;
cursorArgs?: string[];
permissionMode?: PermissionMode;
resumeSessionId?: string;
model?: string;
onSessionReady?: (session: CursorSession) => void;
}
export async function loop(opts: LoopOptions): Promise<void> {
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
});
}
+138
View File
@@ -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<void> {
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<EnhancedMode>((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();
}
}
+78
View File
@@ -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<EnhancedMode> {
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<EnhancedMode>;
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<ApiSessionClient['sendSessionEvent']>[0]): void => {
this.client.sendSessionEvent(event);
};
}
@@ -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' });
});
});
});
@@ -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<string, unknown>;
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, unknown>): 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<string, unknown>;
return typeof fn.name === 'string' ? fn.name : 'unknown';
}
return 'unknown';
}
function extractToolInput(toolCall: Record<string, unknown>): unknown {
if (toolCall.readToolCall && typeof toolCall.readToolCall === 'object') {
const r = (toolCall.readToolCall as Record<string, unknown>).args;
return r ?? {};
}
if (toolCall.writeToolCall && typeof toolCall.writeToolCall === 'object') {
const w = (toolCall.writeToolCall as Record<string, unknown>).args;
return w ?? {};
}
if (toolCall.function && typeof toolCall.function === 'object') {
const fn = toolCall.function as Record<string, unknown>;
return { arguments: fn.arguments };
}
return {};
}
function extractToolResult(toolCall: Record<string, unknown>): unknown {
if (toolCall.readToolCall && typeof toolCall.readToolCall === 'object') {
const r = toolCall.readToolCall as Record<string, unknown>;
return r.result ?? r;
}
if (toolCall.writeToolCall && typeof toolCall.writeToolCall === 'object') {
const w = toolCall.writeToolCall as Record<string, unknown>;
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<string, unknown>;
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;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ export interface SpawnSessionOptions {
sessionId?: string sessionId?: string
resumeSessionId?: string resumeSessionId?: string
approvedNewDirectoryCreation?: boolean approvedNewDirectoryCreation?: boolean
agent?: 'claude' | 'codex' | 'gemini' | 'opencode' agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
model?: string model?: string
yolo?: boolean yolo?: boolean
token?: string token?: string
+9 -5
View File
@@ -325,15 +325,19 @@ export async function startRunner(): Promise<void> {
// Construct arguments for the CLI // Construct arguments for the CLI
const agentCommand = agent === 'codex' const agentCommand = agent === 'codex'
? 'codex' ? 'codex'
: agent === 'gemini' : agent === 'cursor'
? 'gemini' ? 'cursor'
: agent === 'opencode' : agent === 'gemini'
? 'opencode' ? 'gemini'
: 'claude'; : agent === 'opencode'
? 'opencode'
: 'claude';
const args = [agentCommand]; const args = [agentCommand];
if (options.resumeSessionId) { if (options.resumeSessionId) {
if (agent === 'codex') { if (agent === 'codex') {
args.push('resume', options.resumeSessionId); args.push('resume', options.resumeSessionId);
} else if (agent === 'cursor') {
args.push('--resume', options.resumeSessionId);
} else { } else {
args.push('--resume', options.resumeSessionId); args.push('--resume', options.resumeSessionId);
} }
+1
View File
@@ -22,6 +22,7 @@ export default defineConfig({
{ text: 'Installation', link: '/guide/installation' }, { text: 'Installation', link: '/guide/installation' },
{ text: 'PWA', link: '/guide/pwa' }, { text: 'PWA', link: '/guide/pwa' },
{ text: 'How it Works', link: '/guide/how-it-works' }, { text: 'How it Works', link: '/guide/how-it-works' },
{ text: 'Cursor Agent', link: '/guide/cursor' },
{ text: 'Voice Assistant', link: '/guide/voice-assistant' }, { text: 'Voice Assistant', link: '/guide/voice-assistant' },
{ text: 'Why HAPI', link: '/guide/why-hapi' }, { text: 'Why HAPI', link: '/guide/why-hapi' },
{ text: 'FAQ', link: '/guide/faq' } { text: 'FAQ', link: '/guide/faq' }
+62
View File
@@ -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 <chatId> # 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 <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 <chatId>` 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
+14
View File
@@ -18,6 +18,7 @@ Yes, HAPI is open source and free to use under the AGPL-3.0-only license.
- **Claude Code** (recommended) - **Claude Code** (recommended)
- **OpenAI Codex** - **OpenAI Codex**
- **Cursor Agent**
- **Google Gemini** - **Google Gemini**
- **OpenCode** - **OpenCode**
@@ -156,6 +157,19 @@ npm install -g @anthropic-ai/claude-code
export HAPI_CLAUDE_PATH=/path/to/claude 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? ### How do I run diagnostics?
```bash ```bash
+5 -4
View File
@@ -47,7 +47,7 @@ HAPI consists of three interconnected components that work together to provide r
### HAPI CLI ### 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 - Starts and manages coding sessions
- Registers sessions with the HAPI hub - 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:** **Key Commands:**
```bash ```bash
hapi # Start Claude Code session hapi # Start Claude Code session
hapi codex # Start OpenAI Codex session hapi codex # Start OpenAI Codex session
hapi gemini # Start Google Gemini session hapi cursor # Start Cursor Agent session
hapi opencode # Start OpenCode session hapi gemini # Start Google Gemini session
hapi opencode # Start OpenCode session
hapi runner start # Run background service for remote session spawning hapi runner start # Run background service for remote session spawning
``` ```
+5 -2
View File
@@ -4,7 +4,7 @@ Install the HAPI CLI and set up the hub.
## Prerequisites ## 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: Verify your CLI is installed:
@@ -15,6 +15,9 @@ claude --version
# For OpenAI Codex CLI # For OpenAI Codex CLI
codex --version codex --version
# For Cursor Agent CLI
agent --version
# For Google Gemini CLI # For Google Gemini CLI
gemini --version gemini --version
@@ -28,7 +31,7 @@ HAPI has three components:
| Component | Role | Required | | 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 | | **Hub** | Central coordinator: persistence, real-time sync, remote access | Yes |
| **Runner** | Background service for remote session spawning | Optional | | **Runner** | Background service for remote session spawning | Optional |
+1
View File
@@ -14,6 +14,7 @@ export function getAgentName(session: Session): string {
const flavor = session.metadata?.flavor const flavor = session.metadata?.flavor
if (flavor === 'claude') return 'Claude' if (flavor === 'claude') return 'Claude'
if (flavor === 'codex') return 'Codex' if (flavor === 'codex') return 'Codex'
if (flavor === 'cursor') return 'Cursor'
if (flavor === 'gemini') return 'Gemini' if (flavor === 'gemini') return 'Gemini'
if (flavor === 'opencode') return 'OpenCode' if (flavor === 'opencode') return 'OpenCode'
return 'Agent' return 'Agent'
+1 -1
View File
@@ -106,7 +106,7 @@ export class RpcGateway {
async spawnSession( async spawnSession(
machineId: string, machineId: string,
directory: string, directory: string,
agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude',
model?: string, model?: string,
yolo?: boolean, yolo?: boolean,
sessionType?: 'simple' | 'worktree', sessionType?: 'simple' | 'worktree',
+5 -3
View File
@@ -300,7 +300,7 @@ export class SyncEngine {
async spawnSession( async spawnSession(
machineId: string, machineId: string,
directory: string, directory: string,
agent: 'claude' | 'codex' | 'gemini' | 'opencode' = 'claude', agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude',
model?: string, model?: string,
yolo?: boolean, yolo?: boolean,
sessionType?: 'simple' | 'worktree', sessionType?: 'simple' | 'worktree',
@@ -330,7 +330,7 @@ export class SyncEngine {
return { type: 'error', message: 'Session metadata missing path', code: 'resume_unavailable' } 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 ? metadata.flavor
: 'claude' : 'claude'
const resumeToken = flavor === 'codex' const resumeToken = flavor === 'codex'
@@ -339,7 +339,9 @@ export class SyncEngine {
? metadata.geminiSessionId ? metadata.geminiSessionId
: flavor === 'opencode' : flavor === 'opencode'
? metadata.opencodeSessionId ? metadata.opencodeSessionId
: metadata.claudeSessionId : flavor === 'cursor'
? metadata.cursorSessionId
: metadata.claudeSessionId
if (!resumeToken) { if (!resumeToken) {
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
+1 -1
View File
@@ -6,7 +6,7 @@ import { requireMachine } from './guards'
const spawnBodySchema = z.object({ const spawnBodySchema = z.object({
directory: z.string().min(1), 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(), model: z.string().optional(),
yolo: z.boolean().optional(), yolo: z.boolean().optional(),
sessionType: z.enum(['simple', 'worktree']).optional(), sessionType: z.enum(['simple', 'worktree']).optional(),
+11 -2
View File
@@ -10,11 +10,15 @@ export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number]
export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const
export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] 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 = [ export const PERMISSION_MODES = [
'default', 'default',
'acceptEdits', 'acceptEdits',
'bypassPermissions', 'bypassPermissions',
'plan', 'plan',
'ask',
'read-only', 'read-only',
'safe-yolo', 'safe-yolo',
'yolo' 'yolo'
@@ -24,12 +28,13 @@ export type PermissionMode = typeof PERMISSION_MODES[number]
export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const export const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
export type ModelMode = typeof MODEL_MODES[number] 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<PermissionMode, string> = { export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
default: 'Default', default: 'Default',
acceptEdits: 'Accept Edits', acceptEdits: 'Accept Edits',
plan: 'Plan Mode', plan: 'Plan Mode',
ask: 'Ask Mode',
bypassPermissions: 'Yolo', bypassPermissions: 'Yolo',
'read-only': 'Read Only', 'read-only': 'Read Only',
'safe-yolo': 'Safe Yolo', 'safe-yolo': 'Safe Yolo',
@@ -42,6 +47,7 @@ export const PERMISSION_MODE_TONES: Record<PermissionMode, PermissionModeTone> =
default: 'neutral', default: 'neutral',
acceptEdits: 'warning', acceptEdits: 'warning',
plan: 'info', plan: 'info',
ask: 'info',
bypassPermissions: 'danger', bypassPermissions: 'danger',
'read-only': 'warning', 'read-only': 'warning',
'safe-yolo': 'warning', 'safe-yolo': 'warning',
@@ -78,6 +84,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe
if (flavor === 'opencode') { if (flavor === 'opencode') {
return OPENCODE_PERMISSION_MODES return OPENCODE_PERMISSION_MODES
} }
if (flavor === 'cursor') {
return CURSOR_PERMISSION_MODES
}
return CLAUDE_PERMISSION_MODES return CLAUDE_PERMISSION_MODES
} }
@@ -94,7 +103,7 @@ export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?:
} }
export function getModelModesForFlavor(flavor?: string | null): readonly ModelMode[] { 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 []
} }
return MODEL_MODES return MODEL_MODES
+1
View File
@@ -31,6 +31,7 @@ export const MetadataSchema = z.object({
codexSessionId: z.string().optional(), codexSessionId: z.string().optional(),
geminiSessionId: z.string().optional(), geminiSessionId: z.string().optional(),
opencodeSessionId: z.string().optional(), opencodeSessionId: z.string().optional(),
cursorSessionId: z.string().optional(),
tools: z.array(z.string()).optional(), tools: z.array(z.string()).optional(),
slashCommands: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(),
homeDir: z.string().optional(), homeDir: z.string().optional(),
+1
View File
@@ -17,6 +17,7 @@ export type {
AgentFlavor, AgentFlavor,
ClaudePermissionMode, ClaudePermissionMode,
CodexPermissionMode, CodexPermissionMode,
CursorPermissionMode,
GeminiPermissionMode, GeminiPermissionMode,
OpencodePermissionMode, OpencodePermissionMode,
ModelMode, ModelMode,
+1 -1
View File
@@ -372,7 +372,7 @@ export class ApiClient {
async spawnSession( async spawnSession(
machineId: string, machineId: string,
directory: string, directory: string,
agent?: 'claude' | 'codex' | 'gemini' | 'opencode', agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode',
model?: string, model?: string,
yolo?: boolean, yolo?: boolean,
sessionType?: 'simple' | 'worktree', sessionType?: 'simple' | 'worktree',
@@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion' import { applySuggestion } from '@/utils/applySuggestion'
import { usePlatform } from '@/hooks/usePlatform' import { usePlatform } from '@/hooks/usePlatform'
import { usePWAInstall } from '@/hooks/usePWAInstall' import { usePWAInstall } from '@/hooks/usePWAInstall'
import { isCodexFamilyFlavor } from '@/lib/agentFlavorUtils' import { isClaudeFlavor } from '@/lib/agentFlavorUtils'
import { markSkillUsed } from '@/lib/recent-skills' import { markSkillUsed } from '@/lib/recent-skills'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { Autocomplete } from '@/components/ChatInput/Autocomplete'
@@ -324,7 +324,7 @@ export function HappyComposer(props: {
useEffect(() => { useEffect(() => {
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { 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() e.preventDefault()
const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number])
const nextIndex = (currentIndex + 1) % MODEL_MODES.length const nextIndex = (currentIndex + 1) % MODEL_MODES.length
@@ -398,7 +398,7 @@ export function HappyComposer(props: {
}, [onModelModeChange, controlsDisabled, haptic]) }, [onModelModeChange, controlsDisabled, haptic])
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) 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 showSettingsButton = Boolean(showPermissionSettings || showModelSettings)
const showAbortButton = true const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle) const voiceEnabled = Boolean(onVoiceToggle)
@@ -14,7 +14,7 @@ export function AgentSelector(props: {
{t('newSession.agent')} {t('newSession.agent')}
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
{(['claude', 'codex', 'gemini', 'opencode'] as const).map((agentType) => ( {(['claude', 'codex', 'cursor', 'gemini', 'opencode'] as const).map((agentType) => (
<label <label
key={agentType} key={agentType}
className="flex items-center gap-1.5 cursor-pointer" className="flex items-center gap-1.5 cursor-pointer"
+1 -1
View File
@@ -3,7 +3,7 @@ import type { AgentType } from './types'
const AGENT_STORAGE_KEY = 'hapi:newSession:agent' const AGENT_STORAGE_KEY = 'hapi:newSession:agent'
const YOLO_STORAGE_KEY = 'hapi:newSession:yolo' const YOLO_STORAGE_KEY = 'hapi:newSession:yolo'
const VALID_AGENTS: AgentType[] = ['claude', 'codex', 'gemini', 'opencode'] const VALID_AGENTS: AgentType[] = ['claude', 'codex', 'cursor', 'gemini', 'opencode']
export function loadPreferredAgent(): AgentType { export function loadPreferredAgent(): AgentType {
try { try {
+2 -1
View File
@@ -1,4 +1,4 @@
export type AgentType = 'claude' | 'codex' | 'gemini' | 'opencode' export type AgentType = 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
export type SessionType = 'simple' | 'worktree' export type SessionType = 'simple' | 'worktree'
export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = { export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
@@ -14,6 +14,7 @@ export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]
{ value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
], ],
cursor: [],
gemini: [ gemini: [
{ value: 'auto', label: 'Auto' }, { value: 'auto', label: 'Auto' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
+1 -1
View File
@@ -6,7 +6,7 @@ import { queryKeys } from '@/lib/query-keys'
type SpawnInput = { type SpawnInput = {
machineId: string machineId: string
directory: string directory: string
agent?: 'claude' | 'codex' | 'gemini' | 'opencode' agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
model?: string model?: string
yolo?: boolean yolo?: boolean
sessionType?: 'simple' | 'worktree' sessionType?: 'simple' | 'worktree'
+5 -1
View File
@@ -6,6 +6,10 @@ export function isClaudeFlavor(flavor?: string | null): boolean {
return flavor === 'claude' return flavor === 'claude'
} }
export function isCursorFlavor(flavor?: string | null): boolean {
return flavor === 'cursor'
}
export function isKnownFlavor(flavor?: string | null): boolean { export function isKnownFlavor(flavor?: string | null): boolean {
return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor)
} }