refactor(cli): split command routing and modularize RPC handlers

Restructure CLI entry point and RPC handler registration to improve maintainability:

- Introduce command registry pattern with CommandDefinition interface for consistent command structure
- Extract individual command modules (claude, codex, daemon, doctor, gemini, auth, connect, etc.)
- Move RPC handlers into separate feature modules (bash, files, git, directories, ripgrep, difftastic, slashCommands)
- Create shared RPC types and response helpers (rpcTypes.ts, rpcResponses.ts)
- Move SpawnSessionOptions and SpawnSessionResult to rpcTypes for proper type organization
- Simplify index.ts to minimal entrypoint using command registry
- Update imports in apiMachine.ts and daemon/run.ts to use rpcTypes module

This enables:
- Better code organization by feature/command
- Easier testing of individual commands
- Safer RPC handler registration without side effects
- Clearer separation of concerns between routing and business logic
This commit is contained in:
weishu
2026-01-03 17:29:05 +08:00
parent 680a37071e
commit aa0c3f2f0d
30 changed files with 1287 additions and 1117 deletions
+1 -135
View File
@@ -1,135 +1 @@
import { execFile, type ExecFileOptions } from 'child_process'
import { promisify } from 'util'
import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager'
import { validatePath } from './pathSecurity'
const execFileAsync = promisify(execFile)
interface GitStatusRequest {
cwd?: string
timeout?: number
}
interface GitDiffNumstatRequest {
cwd?: string
staged?: boolean
timeout?: number
}
interface GitDiffFileRequest {
cwd?: string
filePath: string
staged?: boolean
timeout?: number
}
interface GitCommandResponse {
success: boolean
stdout?: string
stderr?: string
exitCode?: number
error?: string
}
function resolveCwd(requestedCwd: string | undefined, workingDirectory: string): { cwd: string; error?: string } {
const cwd = requestedCwd ?? workingDirectory
const validation = validatePath(cwd, workingDirectory)
if (!validation.valid) {
return { cwd, error: validation.error ?? 'Invalid working directory' }
}
return { cwd }
}
function validateFilePath(filePath: string, workingDirectory: string): string | null {
const validation = validatePath(filePath, workingDirectory)
if (!validation.valid) {
return validation.error ?? 'Invalid file path'
}
return null
}
async function runGitCommand(
args: string[],
cwd: string,
timeout?: number
): Promise<GitCommandResponse> {
try {
const options: ExecFileOptions = {
cwd,
timeout: timeout ?? 10_000
}
const { stdout, stderr } = await execFileAsync('git', args, options)
return {
success: true,
stdout: stdout ? stdout.toString() : '',
stderr: stderr ? stderr.toString() : '',
exitCode: 0
}
} catch (error) {
const execError = error as NodeJS.ErrnoException & {
stdout?: string
stderr?: string
code?: number | string
killed?: boolean
}
if (execError.code === 'ETIMEDOUT' || execError.killed) {
return {
success: false,
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : '',
exitCode: typeof execError.code === 'number' ? execError.code : -1,
error: 'Command timed out'
}
}
return {
success: false,
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed',
exitCode: typeof execError.code === 'number' ? execError.code : 1,
error: execError.message || 'Command failed'
}
}
}
export function registerGitHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<GitStatusRequest, GitCommandResponse>('git-status', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return { success: false, error: resolved.error }
}
return await runGitCommand(
['status', '--porcelain=v2', '--branch', '--untracked-files=all'],
resolved.cwd,
data.timeout
)
})
rpcHandlerManager.registerHandler<GitDiffNumstatRequest, GitCommandResponse>('git-diff-numstat', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return { success: false, error: resolved.error }
}
const args = data.staged
? ['diff', '--cached', '--numstat']
: ['diff', '--numstat']
return await runGitCommand(args, resolved.cwd, data.timeout)
})
rpcHandlerManager.registerHandler<GitDiffFileRequest, GitCommandResponse>('git-diff-file', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return { success: false, error: resolved.error }
}
const fileError = validateFilePath(data.filePath, workingDirectory)
if (fileError) {
return { success: false, error: fileError }
}
const args = data.staged
? ['diff', '--cached', '--no-ext-diff', '--', data.filePath]
: ['diff', '--no-ext-diff', '--', data.filePath]
return await runGitCommand(args, resolved.cwd, data.timeout)
})
}
export { registerGitHandlers } from './handlers/git'
+72
View File
@@ -0,0 +1,72 @@
import { logger } from '@/ui/logger'
import { exec, type ExecOptions } from 'child_process'
import { promisify } from 'util'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
const execAsync = promisify(exec)
interface BashRequest {
command: string
cwd?: string
timeout?: number
}
interface BashResponse {
success: boolean
stdout?: string
stderr?: string
exitCode?: number
error?: string
}
export function registerBashHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<BashRequest, BashResponse>('bash', async (data) => {
logger.debug('Shell command request:', data.command)
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid working directory')
}
}
try {
const options: ExecOptions = {
cwd: data.cwd,
timeout: data.timeout || 30000
}
const { stdout, stderr } = await execAsync(data.command, options)
return {
success: true,
stdout: stdout ? stdout.toString() : '',
stderr: stderr ? stderr.toString() : '',
exitCode: 0
}
} catch (error) {
const execError = error as NodeJS.ErrnoException & {
stdout?: string
stderr?: string
code?: number | string
killed?: boolean
}
if (execError.code === 'ETIMEDOUT' || execError.killed) {
return rpcError('Command timed out', {
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : '',
exitCode: typeof execError.code === 'number' ? execError.code : -1
})
}
return rpcError(getErrorMessage(execError, 'Command failed'), {
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed',
exitCode: typeof execError.code === 'number' ? execError.code : 1
})
}
})
}
@@ -0,0 +1,44 @@
import { logger } from '@/ui/logger'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { run as runDifftastic } from '@/modules/difftastic/index'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
interface DifftasticRequest {
args: string[]
cwd?: string
}
interface DifftasticResponse {
success: boolean
exitCode?: number
stdout?: string
stderr?: string
error?: string
}
export function registerDifftasticHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<DifftasticRequest, DifftasticResponse>('difftastic', async (data) => {
logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd)
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid working directory')
}
}
try {
const result = await runDifftastic(data.args, { cwd: data.cwd })
return {
success: true,
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString()
}
} catch (error) {
logger.debug('Failed to run difftastic:', error)
return rpcError(getErrorMessage(error, 'Failed to run difftastic'))
}
})
}
@@ -0,0 +1,173 @@
import { logger } from '@/ui/logger'
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
interface ListDirectoryRequest {
path: string
}
interface DirectoryEntry {
name: string
type: 'file' | 'directory' | 'other'
size?: number
modified?: number
}
interface ListDirectoryResponse {
success: boolean
entries?: DirectoryEntry[]
error?: string
}
interface GetDirectoryTreeRequest {
path: string
maxDepth: number
}
interface TreeNode {
name: string
path: string
type: 'file' | 'directory'
size?: number
modified?: number
children?: TreeNode[]
}
interface GetDirectoryTreeResponse {
success: boolean
tree?: TreeNode
error?: string
}
export function registerDirectoryHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => {
logger.debug('List directory request:', data.path)
const validation = validatePath(data.path, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
try {
const entries = await readdir(data.path, { withFileTypes: true })
const directoryEntries: DirectoryEntry[] = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(data.path, entry.name)
let type: 'file' | 'directory' | 'other' = 'other'
let size: number | undefined
let modified: number | undefined
if (entry.isDirectory()) {
type = 'directory'
} else if (entry.isFile()) {
type = 'file'
}
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch (error) {
logger.debug(`Failed to stat ${fullPath}:`, error)
}
return {
name: entry.name,
type,
size,
modified
}
})
)
directoryEntries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
return { success: true, entries: directoryEntries }
} catch (error) {
logger.debug('Failed to list directory:', error)
return rpcError(getErrorMessage(error, 'Failed to list directory'))
}
})
rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth)
const validation = validatePath(data.path, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid directory path')
}
async function buildTree(path: string, name: string, currentDepth: number): Promise<TreeNode | null> {
try {
const stats = await stat(path)
const node: TreeNode = {
name,
path,
type: stats.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtime.getTime()
}
if (stats.isDirectory() && currentDepth < data.maxDepth) {
const entries = await readdir(path, { withFileTypes: true })
const children: TreeNode[] = []
await Promise.all(
entries.map(async (entry) => {
if (entry.isSymbolicLink()) {
logger.debug(`Skipping symlink: ${join(path, entry.name)}`)
return
}
const childPath = join(path, entry.name)
const childNode = await buildTree(childPath, entry.name, currentDepth + 1)
if (childNode) {
children.push(childNode)
}
})
)
children.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
node.children = children
}
return node
} catch (error) {
logger.debug(`Failed to process ${path}:`, error instanceof Error ? error.message : String(error))
return null
}
}
try {
if (data.maxDepth < 0) {
return rpcError('maxDepth must be non-negative')
}
const baseName = data.path === '/' ? '/' : basename(data.path) || data.path
const tree = await buildTree(data.path, baseName, 0)
if (!tree) {
return rpcError('Failed to access the specified path')
}
return { success: true, tree }
} catch (error) {
logger.debug('Failed to get directory tree:', error)
return rpcError(getErrorMessage(error, 'Failed to get directory tree'))
}
})
}
+98
View File
@@ -0,0 +1,98 @@
import { logger } from '@/ui/logger'
import { readFile, stat, writeFile } from 'fs/promises'
import { createHash } from 'crypto'
import { resolve } from 'path'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
interface ReadFileRequest {
path: string
}
interface ReadFileResponse {
success: boolean
content?: string
error?: string
}
interface WriteFileRequest {
path: string
content: string
expectedHash?: string | null
}
interface WriteFileResponse {
success: boolean
hash?: string
error?: string
}
export function registerFileHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<ReadFileRequest, ReadFileResponse>('readFile', async (data) => {
logger.debug('Read file request:', data.path)
const validation = validatePath(data.path, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid file path')
}
try {
const resolvedPath = resolve(workingDirectory, data.path)
const buffer = await readFile(resolvedPath)
const content = buffer.toString('base64')
return { success: true, content }
} catch (error) {
logger.debug('Failed to read file:', error)
return rpcError(getErrorMessage(error, 'Failed to read file'))
}
})
rpcHandlerManager.registerHandler<WriteFileRequest, WriteFileResponse>('writeFile', async (data) => {
logger.debug('Write file request:', data.path)
const validation = validatePath(data.path, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid file path')
}
try {
if (data.expectedHash !== null && data.expectedHash !== undefined) {
try {
const existingBuffer = await readFile(data.path)
const existingHash = createHash('sha256').update(existingBuffer).digest('hex')
if (existingHash !== data.expectedHash) {
return rpcError(`File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}`)
}
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw error
}
return rpcError('File does not exist but hash was provided')
}
} else {
try {
await stat(data.path)
return rpcError('File already exists but was expected to be new')
} catch (error) {
const nodeError = error as NodeJS.ErrnoException
if (nodeError.code !== 'ENOENT') {
throw error
}
}
}
const buffer = Buffer.from(data.content, 'base64')
await writeFile(data.path, buffer)
const hash = createHash('sha256').update(buffer).digest('hex')
return { success: true, hash }
} catch (error) {
logger.debug('Failed to write file:', error)
return rpcError(getErrorMessage(error, 'Failed to write file'))
}
})
}
+132
View File
@@ -0,0 +1,132 @@
import { execFile, type ExecFileOptions } from 'child_process'
import { promisify } from 'util'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { validatePath } from '../pathSecurity'
import { rpcError } from '../rpcResponses'
const execFileAsync = promisify(execFile)
interface GitStatusRequest {
cwd?: string
timeout?: number
}
interface GitDiffNumstatRequest {
cwd?: string
staged?: boolean
timeout?: number
}
interface GitDiffFileRequest {
cwd?: string
filePath: string
staged?: boolean
timeout?: number
}
interface GitCommandResponse {
success: boolean
stdout?: string
stderr?: string
exitCode?: number
error?: string
}
function resolveCwd(requestedCwd: string | undefined, workingDirectory: string): { cwd: string; error?: string } {
const cwd = requestedCwd ?? workingDirectory
const validation = validatePath(cwd, workingDirectory)
if (!validation.valid) {
return { cwd, error: validation.error ?? 'Invalid working directory' }
}
return { cwd }
}
function validateFilePath(filePath: string, workingDirectory: string): string | null {
const validation = validatePath(filePath, workingDirectory)
if (!validation.valid) {
return validation.error ?? 'Invalid file path'
}
return null
}
async function runGitCommand(
args: string[],
cwd: string,
timeout?: number
): Promise<GitCommandResponse> {
try {
const options: ExecFileOptions = {
cwd,
timeout: timeout ?? 10_000
}
const { stdout, stderr } = await execFileAsync('git', args, options)
return {
success: true,
stdout: stdout ? stdout.toString() : '',
stderr: stderr ? stderr.toString() : '',
exitCode: 0
}
} catch (error) {
const execError = error as NodeJS.ErrnoException & {
stdout?: string
stderr?: string
code?: number | string
killed?: boolean
}
if (execError.code === 'ETIMEDOUT' || execError.killed) {
return rpcError('Command timed out', {
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : '',
exitCode: typeof execError.code === 'number' ? execError.code : -1
})
}
return rpcError(execError.message || 'Command failed', {
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed',
exitCode: typeof execError.code === 'number' ? execError.code : 1
})
}
}
export function registerGitHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<GitStatusRequest, GitCommandResponse>('git-status', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return rpcError(resolved.error)
}
return await runGitCommand(
['status', '--porcelain=v2', '--branch', '--untracked-files=all'],
resolved.cwd,
data.timeout
)
})
rpcHandlerManager.registerHandler<GitDiffNumstatRequest, GitCommandResponse>('git-diff-numstat', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return rpcError(resolved.error)
}
const args = data.staged
? ['diff', '--cached', '--numstat']
: ['diff', '--numstat']
return await runGitCommand(args, resolved.cwd, data.timeout)
})
rpcHandlerManager.registerHandler<GitDiffFileRequest, GitCommandResponse>('git-diff-file', async (data) => {
const resolved = resolveCwd(data.cwd, workingDirectory)
if (resolved.error) {
return rpcError(resolved.error)
}
const fileError = validateFilePath(data.filePath, workingDirectory)
if (fileError) {
return rpcError(fileError)
}
const args = data.staged
? ['diff', '--cached', '--no-ext-diff', '--', data.filePath]
: ['diff', '--no-ext-diff', '--', data.filePath]
return await runGitCommand(args, resolved.cwd, data.timeout)
})
}
@@ -0,0 +1,44 @@
import { logger } from '@/ui/logger'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { run as runRipgrep } from '@/modules/ripgrep/index'
import { validatePath } from '../pathSecurity'
import { getErrorMessage, rpcError } from '../rpcResponses'
interface RipgrepRequest {
args: string[]
cwd?: string
}
interface RipgrepResponse {
success: boolean
exitCode?: number
stdout?: string
stderr?: string
error?: string
}
export function registerRipgrepHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
rpcHandlerManager.registerHandler<RipgrepRequest, RipgrepResponse>('ripgrep', async (data) => {
logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd)
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory)
if (!validation.valid) {
return rpcError(validation.error ?? 'Invalid working directory')
}
}
try {
const result = await runRipgrep(data.args, { cwd: data.cwd })
return {
success: true,
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString()
}
} catch (error) {
logger.debug('Failed to run ripgrep:', error)
return rpcError(getErrorMessage(error, 'Failed to run ripgrep'))
}
})
}
@@ -0,0 +1,18 @@
import { logger } from '@/ui/logger'
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from '../slashCommands'
import { getErrorMessage, rpcError } from '../rpcResponses'
export function registerSlashCommandHandlers(rpcHandlerManager: RpcHandlerManager): void {
rpcHandlerManager.registerHandler<ListSlashCommandsRequest, ListSlashCommandsResponse>('listSlashCommands', async (data) => {
logger.debug('List slash commands request for agent:', data.agent)
try {
const commands = await listSlashCommands(data.agent)
return { success: true, commands }
} catch (error) {
logger.debug('Failed to list slash commands:', error)
return rpcError(getErrorMessage(error, 'Failed to list slash commands'))
}
})
}
+16 -505
View File
@@ -1,507 +1,18 @@
import { logger } from '@/ui/logger';
import { exec, ExecOptions } from 'child_process';
import { promisify } from 'util';
import { readFile, writeFile, readdir, stat } from 'fs/promises';
import { createHash } from 'crypto';
import { basename, join, resolve } from 'path';
import { run as runRipgrep } from '@/modules/ripgrep/index';
import { run as runDifftastic } from '@/modules/difftastic/index';
import { RpcHandlerManager } from '../../api/rpc/RpcHandlerManager';
import { registerGitHandlers } from './gitHandlers';
import { validatePath } from './pathSecurity';
import { listSlashCommands, type ListSlashCommandsRequest, type ListSlashCommandsResponse } from './slashCommands';
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { registerBashHandlers } from './handlers/bash'
import { registerDirectoryHandlers } from './handlers/directories'
import { registerDifftasticHandlers } from './handlers/difftastic'
import { registerFileHandlers } from './handlers/files'
import { registerGitHandlers } from './gitHandlers'
import { registerRipgrepHandlers } from './handlers/ripgrep'
import { registerSlashCommandHandlers } from './handlers/slashCommands'
const execAsync = promisify(exec);
interface BashRequest {
command: string;
cwd?: string;
timeout?: number; // timeout in milliseconds
}
interface BashResponse {
success: boolean;
stdout?: string;
stderr?: string;
exitCode?: number;
error?: string;
}
interface ReadFileRequest {
path: string;
}
interface ReadFileResponse {
success: boolean;
content?: string; // base64 encoded
error?: string;
}
interface WriteFileRequest {
path: string;
content: string; // base64 encoded
expectedHash?: string | null; // null for new files, hash for existing files
}
interface WriteFileResponse {
success: boolean;
hash?: string; // hash of written file
error?: string;
}
interface ListDirectoryRequest {
path: string;
}
interface DirectoryEntry {
name: string;
type: 'file' | 'directory' | 'other';
size?: number;
modified?: number; // timestamp
}
interface ListDirectoryResponse {
success: boolean;
entries?: DirectoryEntry[];
error?: string;
}
interface GetDirectoryTreeRequest {
path: string;
maxDepth: number;
}
interface TreeNode {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
modified?: number;
children?: TreeNode[]; // Only present for directories
}
interface GetDirectoryTreeResponse {
success: boolean;
tree?: TreeNode;
error?: string;
}
interface RipgrepRequest {
args: string[];
cwd?: string;
}
interface RipgrepResponse {
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}
interface DifftasticRequest {
args: string[];
cwd?: string;
}
interface DifftasticResponse {
success: boolean;
exitCode?: number;
stdout?: string;
stderr?: string;
error?: string;
}
/*
* Spawn Session Options and Result
* This rpc type is used by the daemon, all other RPCs here are for sessions
*/
export interface SpawnSessionOptions {
machineId?: string;
directory: string;
sessionId?: string;
approvedNewDirectoryCreation?: boolean;
agent?: 'claude' | 'codex' | 'gemini';
yolo?: boolean;
token?: string;
sessionType?: 'simple' | 'worktree';
worktreeName?: string;
}
export type SpawnSessionResult =
| { type: 'success'; sessionId: string }
| { type: 'requestToApproveDirectoryCreation'; directory: string }
| { type: 'error'; errorMessage: string };
/**
* Register all RPC handlers with the session
*/
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string) {
// Shell command handler - executes commands in the default shell
rpcHandlerManager.registerHandler<BashRequest, BashResponse>('bash', async (data) => {
logger.debug('Shell command request:', data.command);
// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}
try {
// Build options with shell enabled by default
// Note: ExecOptions doesn't support boolean for shell, but exec() uses the default shell when shell is undefined
const options: ExecOptions = {
cwd: data.cwd,
timeout: data.timeout || 30000, // Default 30 seconds timeout
};
const { stdout, stderr } = await execAsync(data.command, options);
return {
success: true,
stdout: stdout ? stdout.toString() : '',
stderr: stderr ? stderr.toString() : '',
exitCode: 0
};
} catch (error) {
const execError = error as NodeJS.ErrnoException & {
stdout?: string;
stderr?: string;
code?: number | string;
killed?: boolean;
};
// Check if the error was due to timeout
if (execError.code === 'ETIMEDOUT' || execError.killed) {
return {
success: false,
stdout: execError.stdout || '',
stderr: execError.stderr || '',
exitCode: typeof execError.code === 'number' ? execError.code : -1,
error: 'Command timed out'
};
}
// If exec fails, it includes stdout/stderr in the error
return {
success: false,
stdout: execError.stdout ? execError.stdout.toString() : '',
stderr: execError.stderr ? execError.stderr.toString() : execError.message || 'Command failed',
exitCode: typeof execError.code === 'number' ? execError.code : 1,
error: execError.message || 'Command failed'
};
}
});
// Read file handler - returns base64 encoded content
rpcHandlerManager.registerHandler<ReadFileRequest, ReadFileResponse>('readFile', async (data) => {
logger.debug('Read file request:', data.path);
// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
try {
const resolvedPath = resolve(workingDirectory, data.path);
const buffer = await readFile(resolvedPath);
const content = buffer.toString('base64');
return { success: true, content };
} catch (error) {
logger.debug('Failed to read file:', error);
return { success: false, error: error instanceof Error ? error.message : 'Failed to read file' };
}
});
// Write file handler - with hash verification
rpcHandlerManager.registerHandler<WriteFileRequest, WriteFileResponse>('writeFile', async (data) => {
logger.debug('Write file request:', data.path);
// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
try {
// If expectedHash is provided (not null), verify existing file
if (data.expectedHash !== null && data.expectedHash !== undefined) {
try {
const existingBuffer = await readFile(data.path);
const existingHash = createHash('sha256').update(existingBuffer).digest('hex');
if (existingHash !== data.expectedHash) {
return {
success: false,
error: `File hash mismatch. Expected: ${data.expectedHash}, Actual: ${existingHash}`
};
}
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error;
}
// File doesn't exist but hash was provided
return {
success: false,
error: 'File does not exist but hash was provided'
};
}
} else {
// expectedHash is null - expecting new file
try {
await stat(data.path);
// File exists but we expected it to be new
return {
success: false,
error: 'File already exists but was expected to be new'
};
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error;
}
// File doesn't exist - this is expected
}
}
// Write the file
const buffer = Buffer.from(data.content, 'base64');
await writeFile(data.path, buffer);
// Calculate and return hash of written file
const hash = createHash('sha256').update(buffer).digest('hex');
return { success: true, hash };
} catch (error) {
logger.debug('Failed to write file:', error);
return { success: false, error: error instanceof Error ? error.message : 'Failed to write file' };
}
});
// List directory handler
rpcHandlerManager.registerHandler<ListDirectoryRequest, ListDirectoryResponse>('listDirectory', async (data) => {
logger.debug('List directory request:', data.path);
// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
try {
const entries = await readdir(data.path, { withFileTypes: true });
const directoryEntries: DirectoryEntry[] = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(data.path, entry.name);
let type: 'file' | 'directory' | 'other' = 'other';
let size: number | undefined;
let modified: number | undefined;
if (entry.isDirectory()) {
type = 'directory';
} else if (entry.isFile()) {
type = 'file';
}
try {
const stats = await stat(fullPath);
size = stats.size;
modified = stats.mtime.getTime();
} catch (error) {
// Ignore stat errors for individual files
logger.debug(`Failed to stat ${fullPath}:`, error);
}
return {
name: entry.name,
type,
size,
modified
};
})
);
// Sort entries: directories first, then files, alphabetically
directoryEntries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
return { success: true, entries: directoryEntries };
} catch (error) {
logger.debug('Failed to list directory:', error);
return { success: false, error: error instanceof Error ? error.message : 'Failed to list directory' };
}
});
// Get directory tree handler - recursive with depth control
rpcHandlerManager.registerHandler<GetDirectoryTreeRequest, GetDirectoryTreeResponse>('getDirectoryTree', async (data) => {
logger.debug('Get directory tree request:', data.path, 'maxDepth:', data.maxDepth);
// Validate path is within working directory
const validation = validatePath(data.path, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
// Helper function to build tree recursively
async function buildTree(path: string, name: string, currentDepth: number): Promise<TreeNode | null> {
try {
const stats = await stat(path);
// Base node information
const node: TreeNode = {
name,
path,
type: stats.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtime.getTime()
};
// If it's a directory and we haven't reached max depth, get children
if (stats.isDirectory() && currentDepth < data.maxDepth) {
const entries = await readdir(path, { withFileTypes: true });
const children: TreeNode[] = [];
// Process entries in parallel, filtering out symlinks
await Promise.all(
entries.map(async (entry) => {
// Skip symbolic links completely
if (entry.isSymbolicLink()) {
logger.debug(`Skipping symlink: ${join(path, entry.name)}`);
return;
}
const childPath = join(path, entry.name);
const childNode = await buildTree(childPath, entry.name, currentDepth + 1);
if (childNode) {
children.push(childNode);
}
})
);
// Sort children: directories first, then files, alphabetically
children.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
node.children = children;
}
return node;
} catch (error) {
// Log error but continue traversal
logger.debug(`Failed to process ${path}:`, error instanceof Error ? error.message : String(error));
return null;
}
}
try {
// Validate maxDepth
if (data.maxDepth < 0) {
return { success: false, error: 'maxDepth must be non-negative' };
}
// Get the base name for the root node (cross-platform)
const baseName = data.path === '/' ? '/' : basename(data.path) || data.path;
// Build the tree starting from the requested path
const tree = await buildTree(data.path, baseName, 0);
if (!tree) {
return { success: false, error: 'Failed to access the specified path' };
}
return { success: true, tree };
} catch (error) {
logger.debug('Failed to get directory tree:', error);
return { success: false, error: error instanceof Error ? error.message : 'Failed to get directory tree' };
}
});
// Ripgrep handler - raw interface to ripgrep
rpcHandlerManager.registerHandler<RipgrepRequest, RipgrepResponse>('ripgrep', async (data) => {
logger.debug('Ripgrep request with args:', data.args, 'cwd:', data.cwd);
// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}
try {
const result = await runRipgrep(data.args, { cwd: data.cwd });
return {
success: true,
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString()
};
} catch (error) {
logger.debug('Failed to run ripgrep:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to run ripgrep'
};
}
});
// Difftastic handler - raw interface to difftastic
rpcHandlerManager.registerHandler<DifftasticRequest, DifftasticResponse>('difftastic', async (data) => {
logger.debug('Difftastic request with args:', data.args, 'cwd:', data.cwd);
// Validate cwd if provided
if (data.cwd) {
const validation = validatePath(data.cwd, workingDirectory);
if (!validation.valid) {
return { success: false, error: validation.error };
}
}
try {
const result = await runDifftastic(data.args, { cwd: data.cwd });
return {
success: true,
exitCode: result.exitCode,
stdout: result.stdout.toString(),
stderr: result.stderr.toString()
};
} catch (error) {
logger.debug('Failed to run difftastic:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to run difftastic'
};
}
});
// Slash commands handler - lists available slash commands for an agent
rpcHandlerManager.registerHandler<ListSlashCommandsRequest, ListSlashCommandsResponse>('listSlashCommands', async (data) => {
logger.debug('List slash commands request for agent:', data.agent);
try {
const commands = await listSlashCommands(data.agent);
return { success: true, commands };
} catch (error) {
logger.debug('Failed to list slash commands:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to list slash commands'
};
}
});
registerGitHandlers(rpcHandlerManager, workingDirectory);
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
registerBashHandlers(rpcHandlerManager, workingDirectory)
registerFileHandlers(rpcHandlerManager, workingDirectory)
registerDirectoryHandlers(rpcHandlerManager, workingDirectory)
registerRipgrepHandlers(rpcHandlerManager, workingDirectory)
registerDifftasticHandlers(rpcHandlerManager, workingDirectory)
registerSlashCommandHandlers(rpcHandlerManager)
registerGitHandlers(rpcHandlerManager, workingDirectory)
}
+23
View File
@@ -0,0 +1,23 @@
export type RpcErrorResponse = { success: false; error: string }
export type RpcSuccessResponse<T extends object> = { success: true } & T
export function rpcError<T extends Record<string, unknown> = Record<string, unknown>>(
message: string,
extras?: T
): RpcErrorResponse & T {
const payload = {
success: false,
error: message,
...(extras ?? {})
}
return payload as RpcErrorResponse & T
}
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message
}
return fallback
}
+16
View File
@@ -0,0 +1,16 @@
export interface SpawnSessionOptions {
machineId?: string
directory: string
sessionId?: string
approvedNewDirectoryCreation?: boolean
agent?: 'claude' | 'codex' | 'gemini'
yolo?: boolean
token?: string
sessionType?: 'simple' | 'worktree'
worktreeName?: string
}
export type SpawnSessionResult =
| { type: 'success'; sessionId: string }
| { type: 'requestToApproveDirectoryCreation'; directory: string }
| { type: 'error'; errorMessage: string }