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
+1
View File
@@ -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
+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 { 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,
+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
resumeSessionId?: string
approvedNewDirectoryCreation?: boolean
agent?: 'claude' | 'codex' | 'gemini' | 'opencode'
agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode'
model?: string
yolo?: boolean
token?: string
+9 -5
View File
@@ -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);
}