mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -325,15 +325,19 @@ export async function startRunner(): Promise<void> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user