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:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type {
|
|||||||
AgentFlavor,
|
AgentFlavor,
|
||||||
ClaudePermissionMode,
|
ClaudePermissionMode,
|
||||||
CodexPermissionMode,
|
CodexPermissionMode,
|
||||||
|
CursorPermissionMode,
|
||||||
GeminiPermissionMode,
|
GeminiPermissionMode,
|
||||||
OpencodePermissionMode,
|
OpencodePermissionMode,
|
||||||
ModelMode,
|
ModelMode,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user