Files
lingxi-ai/packages/server/src/services/coding-agents.ts
T

1101 lines
36 KiB
TypeScript
Raw Normal View History

2026-06-05 11:29:11 +08:00
import { execFile } from 'child_process'
import { existsSync, readdirSync, realpathSync } from 'fs'
import { mkdir, readFile, stat, writeFile } from 'fs/promises'
import { homedir } from 'os'
import { delimiter, dirname, extname, join } from 'path'
import { promisify } from 'util'
import { getWebUiHome } from '../config'
import { registerClaudeCodeProxyTarget, type ApiMode } from './claude-code-proxy'
import { registerCodexProxyTarget } from './codex-proxy'
import { PROVIDER_PRESETS } from '../shared/providers'
import { getModelContextLength } from './hermes/model-context'
const execFileAsync = promisify(execFile)
const LAUNCH_API_MODES = new Set<ApiMode>(['chat_completions', 'codex_responses', 'anthropic_messages'])
const CODING_AGENT_HOME_DIR = 'coding-agent'
const CODEX_MODEL_CATALOG_FILE = 'codex-model-catalog.json'
const CODEX_CATALOG_BASE_INSTRUCTIONS = 'You are Codex, a coding agent. Be precise, safe, and helpful.'
const NODE_ENVIRONMENT_MISSING_CODE = 'node_environment_missing'
export type CodingAgentId = 'claude-code' | 'codex'
export interface CodingAgentDefinition {
id: CodingAgentId
name: string
provider: string
command: string
packageName: string
}
export interface CodingAgentToolStatus extends CodingAgentDefinition {
installed: boolean
version: string
rawVersion: string
error?: string
}
export interface CodingAgentsStatus {
tools: CodingAgentToolStatus[]
}
export interface CodingAgentMutationResult extends CodingAgentsStatus {
success: boolean
tool: CodingAgentToolStatus
message?: string
code?: string
}
export interface CodingAgentConfigFileDefinition {
key: string
path: string
absolutePath: string
language: string
}
export interface CodingAgentConfigScope {
profile?: string
provider?: string
}
export interface CodingAgentConfigFileContent extends CodingAgentConfigFileDefinition {
content: string
exists: boolean
size: number
profile: string
provider: string
rootDir: string
}
export interface CodingAgentLaunchInput extends CodingAgentConfigScope {
mode?: 'scoped' | 'global'
model?: string
baseUrl?: string
apiKey?: string
apiMode?: ApiMode
}
export interface CodingAgentLaunchResult {
agentId: CodingAgentId
mode: 'scoped' | 'global'
profile: string
provider: string
model: string
rootDir: string
workspaceDir: string
command: string
args: string[]
env: Record<string, string>
shellCommand: string
files: Array<{ key: string; path: string; absolutePath: string }>
}
export interface CodingAgentNativeLaunchResult extends CodingAgentLaunchResult {
nativeTerminal: true
terminal: string
}
const TOOL_DEFINITIONS: CodingAgentDefinition[] = [
{
id: 'claude-code',
name: 'Claude Code',
provider: 'Anthropic',
command: 'claude',
packageName: '@anthropic-ai/claude-code',
},
{
id: 'codex',
name: 'Codex',
provider: 'OpenAI',
command: 'codex',
packageName: '@openai/codex',
},
]
const CONFIG_FILE_DEFINITIONS: Record<CodingAgentId, Array<Omit<CodingAgentConfigFileDefinition, 'absolutePath'> & { scopedPath: string }>> = {
'claude-code': [
{ key: 'settings', path: '~/.claude/settings.json', scopedPath: 'settings.json', language: 'json' },
{ key: 'mcp', path: '~/.claude.json', scopedPath: 'mcp.json', language: 'json' },
{ key: 'prompt', path: '~/.claude/CLAUDE.md', scopedPath: 'CLAUDE.md', language: 'markdown' },
],
codex: [
{ key: 'auth', path: '~/.codex/auth.json', scopedPath: 'auth.json', language: 'json' },
{ key: 'config', path: '~/.codex/config.toml', scopedPath: 'config.toml', language: 'ini' },
{ key: 'agents', path: '~/.codex/AGENTS.md', scopedPath: 'AGENTS.md', language: 'markdown' },
],
}
const installingTools = new Set<CodingAgentId>()
const deletingTools = new Set<CodingAgentId>()
let cachedGlobalNpmBin: string | null | undefined
const MAX_CONFIG_FILE_SIZE = parseInt(process.env.MAX_EDIT_SIZE || '', 10) || 10 * 1024 * 1024
function getNodeBinDir() {
return dirname(process.execPath)
}
function getNodePrefix() {
return process.platform === 'win32' ? getNodeBinDir() : dirname(getNodeBinDir())
}
function getHomebrewPrefix() {
const match = process.execPath.match(/^(.*)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/)
return match?.[1] || null
}
function getNpmCliCandidates() {
const prefix = getNodePrefix()
const homebrewPrefix = getHomebrewPrefix()
return process.platform === 'win32'
? [
join(prefix, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
join(getNodeBinDir(), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
]
: [
join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
...(homebrewPrefix ? [join(homebrewPrefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')] : []),
]
}
function getNpmCliPath() {
return getNpmCliCandidates().find(existsSync) || null
}
function getNpmBin() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm'
}
function compareNodeVersionDesc(left: string, right: string): number {
const leftParts = left.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0)
const rightParts = right.replace(/^v/, '').split('.').map(part => Number.parseInt(part, 10) || 0)
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) {
const diff = (rightParts[index] || 0) - (leftParts[index] || 0)
if (diff !== 0) return diff
}
return right.localeCompare(left)
}
function getNvmNodeBinPaths(): string {
if (process.env.HERMES_DESKTOP !== 'true' || process.platform === 'win32') return ''
const nvmDir = process.env.NVM_DIR?.trim() || join(homedir(), '.nvm')
const versionsDir = join(nvmDir, 'versions', 'node')
if (!existsSync(versionsDir)) return ''
try {
return readdirSync(versionsDir, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort(compareNodeVersionDesc)
.map(version => join(versionsDir, version, 'bin'))
.filter(binDir => existsSync(binDir))
.join(delimiter)
} catch {
return ''
}
}
function nodeEnvironmentMissingError(): Error {
const err = new Error('Node/npm environment was not detected. Please install Node.js and try again.')
;(err as any).code = NODE_ENVIRONMENT_MISSING_CODE
return err
}
function isNodeEnvironmentMissingError(err: any): boolean {
const text = [
err?.code,
err?.message,
typeof err?.stderr === 'string' ? err.stderr : '',
typeof err?.stdout === 'string' ? err.stdout : '',
].filter(Boolean).join('\n').toLowerCase()
return text.includes('enoent') ||
text.includes('spawn npm') ||
text.includes('npm: command not found') ||
text.includes('npm not found') ||
text.includes('node: command not found') ||
text.includes('node not found')
}
function npmCliFromNpmBin(npmBin: string): { node: string; npmCli: string } | null {
const binDir = dirname(npmBin)
if (process.platform === 'win32') {
const node = join(binDir, 'node.exe')
const npmCli = join(binDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')
return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null
}
const node = join(binDir, 'node')
const npmCli = join(dirname(binDir), 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')
return existsSync(node) && existsSync(npmCli) ? { node, npmCli } : null
}
function normalizeScopeSegment(value: string | undefined, fallback: string, label: string): string {
// Replace invalid filename characters with underscores
// Windows invalid chars: < > : " / \ | ? *
// Additional problematic chars: control characters
const sanitizedValue = String(value || '').trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
const segment = sanitizedValue || fallback
if (
segment === '.' ||
segment === '..' ||
segment.includes('\0')
) {
const err = new Error(`Invalid ${label}`)
;(err as any).status = 400
throw err
}
if (segment.length > 128) {
const err = new Error(`${label} is too long`)
;(err as any).status = 400
throw err
}
return segment
}
function normalizeConfigScope(scope: CodingAgentConfigScope = {}): Required<CodingAgentConfigScope> {
return {
profile: normalizeScopeSegment(scope.profile, 'default', 'profile'),
provider: normalizeScopeSegment(scope.provider, 'default', 'provider'),
}
}
function normalizeLaunchApiMode(value: unknown, fallback: ApiMode): ApiMode {
if (!value) return fallback
const mode = String(value).trim() as ApiMode
if (!LAUNCH_API_MODES.has(mode)) {
const err = new Error('Invalid API protocol')
;(err as any).status = 400
throw err
}
return mode
}
function getScopedConfigRoot(id: CodingAgentId, scope: Required<CodingAgentConfigScope>): string {
return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'model', scope.profile, scope.provider, id)
}
function getScopedWorkspaceRoot(scope: Required<CodingAgentConfigScope>): string {
return join(getWebUiHome(), CODING_AGENT_HOME_DIR, 'workspace', scope.profile, scope.provider)
}
function displayNameForModel(model: string): string {
const trimmed = model.trim()
if (!trimmed) return 'Model'
const leaf = trimmed.split('/').filter(Boolean).pop() || trimmed
return leaf
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, char => char.toUpperCase())
}
function codexCatalogEntry(input: {
model: string
displayName: string
contextWindow: number
priority: number
}) {
return {
slug: input.model,
display_name: input.displayName,
description: input.displayName,
default_reasoning_level: 'medium',
supported_reasoning_levels: [
{ effort: 'low', description: 'Fast responses with lighter reasoning' },
{ effort: 'medium', description: 'Balances speed and reasoning depth for everyday tasks' },
{ effort: 'high', description: 'Greater reasoning depth for complex problems' },
{ effort: 'xhigh', description: 'Extra high reasoning depth for complex problems' },
],
shell_type: 'shell_command',
visibility: 'list',
supported_in_api: true,
priority: 1000 + input.priority,
additional_speed_tiers: [],
service_tiers: [],
default_service_tier: null,
availability_nux: null,
upgrade: null,
base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS,
model_messages: {
instructions_template: '{{ base_instructions }}\n\n{{ personality }}',
instructions_variables: {
base_instructions: CODEX_CATALOG_BASE_INSTRUCTIONS,
personality: '',
personality_default: '',
personality_friendly: '',
personality_pragmatic: '',
},
},
supports_reasoning_summaries: true,
default_reasoning_summary: 'none',
support_verbosity: true,
default_verbosity: 'low',
apply_patch_tool_type: 'freeform',
web_search_tool_type: 'text_and_image',
truncation_policy: { mode: 'tokens', limit: 10_000 },
supports_parallel_tool_calls: true,
supports_image_detail_original: true,
context_window: input.contextWindow,
max_context_window: input.contextWindow,
effective_context_window_percent: 95,
experimental_supported_tools: [],
input_modalities: ['text'],
supports_search_tool: true,
}
}
function buildCodexModelCatalog(input: {
profile: string
provider: string
model: string
presetModels: string[]
}) {
const models = [...new Set([input.model, ...input.presetModels].map(item => item.trim()).filter(Boolean))]
return {
models: models.map((model, index) => codexCatalogEntry({
model,
displayName: displayNameForModel(model),
contextWindow: getModelContextLength({ profile: input.profile, provider: input.provider, model }),
priority: index,
})),
}
}
function expandHomePath(path: string): string {
if (path === '~') return homedir()
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
return path
}
function shellQuote(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value
return `'${value.replace(/'/g, `'\\''`)}'`
}
function powerShellQuote(value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
function cmdQuote(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}
function buildLaunchShellCommand(input: {
workspaceDir: string
env: Record<string, string>
command: string
args: string[]
}): string {
if (process.platform === 'win32') {
const envAssignments = Object.entries(input.env)
.map(([key, value]) => `$env:${key} = ${powerShellQuote(value)}`)
return [
`Set-Location -LiteralPath ${powerShellQuote(input.workspaceDir)}`,
...envAssignments,
`& ${powerShellQuote(input.command)} ${input.args.map(powerShellQuote).join(' ')}`.trim(),
].join('; ')
}
const envPrefix = Object.entries(input.env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ')
const runCommand = [
envPrefix,
input.command,
...input.args.map(shellQuote),
].filter(Boolean).join(' ')
return `cd ${shellQuote(input.workspaceDir)} && ${runCommand}`
}
function appleScriptString(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
async function commandExists(command: string): Promise<boolean> {
try {
await execFileAsync(process.platform === 'win32' ? 'where' : 'which', [command], {
encoding: 'utf-8',
timeout: 3000,
windowsHide: true,
})
return true
} catch {
return false
}
}
function isDockerRuntime(): boolean {
return existsSync('/.dockerenv') || process.env.container === 'docker'
}
async function openNativeTerminal(shellCommand: string): Promise<string> {
if (process.platform === 'win32') {
const escapedCommand = shellCommand.replace(/"/g, '""').replace(/\$/g, '`$')
await execFileAsync('powershell.exe', [
'-NoProfile',
'-Command',
`Start-Process -FilePath powershell.exe -ArgumentList @('-NoExit', '-Command', "${escapedCommand}")`,
], {
encoding: 'utf-8',
timeout: 8000,
windowsHide: true,
})
return 'PowerShell'
}
if (process.platform === 'darwin') {
await execFileAsync('osascript', [
'-e',
`tell application "Terminal" to do script ${appleScriptString(shellCommand)}`,
'-e',
'tell application "Terminal" to activate',
], {
encoding: 'utf-8',
timeout: 8000,
windowsHide: true,
})
return 'Terminal.app'
}
if (process.platform === 'linux') {
if (isDockerRuntime()) {
const err = new Error('Native terminal is not available inside Docker')
;(err as any).status = 400
throw err
}
if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
const err = new Error('Native terminal requires a Linux desktop session')
;(err as any).status = 400
throw err
}
const candidates: Array<{ command: string; args: string[] }> = [
{ command: 'xdg-terminal-exec', args: ['bash', '-lc', shellCommand] },
{ command: 'gnome-terminal', args: ['--', 'bash', '-lc', shellCommand] },
{ command: 'konsole', args: ['-e', 'bash', '-lc', shellCommand] },
{ command: 'xfce4-terminal', args: ['--command', `bash -lc ${shellQuote(shellCommand)}`] },
{ command: 'kitty', args: ['bash', '-lc', shellCommand] },
{ command: 'alacritty', args: ['-e', 'bash', '-lc', shellCommand] },
{ command: 'xterm', args: ['-e', 'bash', '-lc', shellCommand] },
]
const errors: string[] = []
for (const candidate of candidates) {
if (!(await commandExists(candidate.command))) continue
try {
await execFileAsync(candidate.command, candidate.args, {
encoding: 'utf-8',
timeout: 8000,
windowsHide: true,
})
return candidate.command
} catch (err: any) {
errors.push(`${candidate.command}: ${normalizeError(err)}`)
}
}
const err = new Error(errors[0] || 'No supported Linux terminal command was found')
;(err as any).status = 400
throw err
}
const err = new Error('Native terminal launch is not supported on this platform')
;(err as any).status = 400
throw err
}
function getLiveConfigFileDefinition(id: string, key: string): CodingAgentConfigFileDefinition | null {
const tool = getCodingAgentDefinition(id)
if (!tool) return null
const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key)
if (!definition) return null
return {
key: definition.key,
path: definition.path,
language: definition.language,
absolutePath: expandHomePath(definition.path),
}
}
function getScopedConfigFileDefinition(id: string, key: string, scopeInput: CodingAgentConfigScope = {}): (CodingAgentConfigFileDefinition & Required<CodingAgentConfigScope> & { rootDir: string }) | null {
const tool = getCodingAgentDefinition(id)
if (!tool) return null
const definition = CONFIG_FILE_DEFINITIONS[tool.id].find(file => file.key === key)
if (!definition) return null
const scope = normalizeConfigScope(scopeInput)
const rootDir = getScopedConfigRoot(tool.id, scope)
return {
key: definition.key,
path: definition.path,
language: definition.language,
...scope,
rootDir,
absolutePath: join(rootDir, definition.scopedPath),
}
}
function getCurrentNodeEnv(): NodeJS.ProcessEnv {
return {
...process.env,
PATH: [getNodeBinDir(), getNvmNodeBinPaths(), process.env.PATH].filter(Boolean).join(delimiter),
npm_node_execpath: process.execPath,
}
}
async function npmExecution(args: string[], env: NodeJS.ProcessEnv): Promise<{ command: string; args: string[] }> {
const bundledNpmCli = getNpmCliPath()
if (bundledNpmCli) return { command: process.execPath, args: [bundledNpmCli, ...args] }
let npmBin: string | null = null
for (const command of [...new Set([getNpmBin(), 'npm'])]) {
const paths = await findCommandPaths(command, env)
if (paths[0]) {
npmBin = paths[0]
break
}
}
if (!npmBin) throw nodeEnvironmentMissingError()
const npmCli = npmCliFromNpmBin(npmBin)
if (npmCli) return { command: npmCli.node, args: [npmCli.npmCli, ...args] }
let nodeBin: string | null = null
for (const command of [...new Set([process.platform === 'win32' ? 'node.exe' : 'node', 'node'])]) {
const paths = await findCommandPaths(command, env)
if (paths[0]) {
nodeBin = paths[0]
break
}
}
if (!nodeBin) throw nodeEnvironmentMissingError()
return commandExecution(npmBin, args)
}
async function runNpm(args: string[], options: { timeout?: number; env?: NodeJS.ProcessEnv } = {}) {
const env = {
...getCurrentNodeEnv(),
...options.env,
}
const execution = await npmExecution(args, env)
return execFileAsync(execution.command, execution.args, {
encoding: 'utf-8',
timeout: options.timeout,
windowsHide: true,
maxBuffer: 10 * 1024 * 1024,
env,
})
}
function normalizeError(err: any): string {
if (isNodeEnvironmentMissingError(err)) return nodeEnvironmentMissingError().message
const stderr = typeof err?.stderr === 'string' ? err.stderr.trim() : ''
const stdout = typeof err?.stdout === 'string' ? err.stdout.trim() : ''
const message = stderr || stdout || err?.message || String(err)
return message.split(/\r?\n/).filter(Boolean).slice(0, 4).join('\n')
}
function normalizeErrorCode(err: any): string | undefined {
return isNodeEnvironmentMissingError(err) ? NODE_ENVIRONMENT_MISSING_CODE : undefined
}
async function findCommandPaths(command: string, env: NodeJS.ProcessEnv): Promise<string[]> {
try {
const lookupCommand = process.platform === 'win32' ? 'where' : 'which'
const lookupArgs = process.platform === 'win32' ? [command] : ['-a', command]
const { stdout } = await execFileAsync(lookupCommand, lookupArgs, {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
env,
})
return stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
} catch {
return []
}
}
function windowsCommandNeedsShell(command: string): boolean {
const extension = extname(command).toLowerCase()
return extension === '.cmd' || extension === '.bat'
}
async function resolveCommandForExecution(command: string, env: NodeJS.ProcessEnv): Promise<string> {
if (process.platform !== 'win32') return command
const paths = await findCommandPaths(command, env)
// On Windows, prioritize paths with .cmd or .bat extensions since where may return
// both the unix-style script (without extension) and the Windows shim (.cmd)
const windowsPath = paths.find(path => windowsCommandNeedsShell(path))
return windowsPath || paths[0] || command
}
function commandExecution(command: string, args: string[]): { command: string; args: string[] } {
if (process.platform === 'win32' && windowsCommandNeedsShell(command)) {
// For CMD /C, the command and args need to be passed as a single string
// The command path should be quoted if it contains spaces, but args are joined directly
const commandArg = / /.test(command) ? `"${command}"` : command
const argsString = args.map(arg => / /.test(arg) ? `"${arg}"` : arg).join(' ')
return {
command: 'cmd.exe',
args: ['/d', '/s', '/c', `${commandArg} ${argsString}`],
}
}
return { command, args }
}
function packageParts(packageName: string): string[] {
return packageName.split('/').filter(Boolean)
}
function getPrefixFromPackagePath(path: string, packageName: string): string | null {
const normalized = path.replace(/\\/g, '/')
const parts = normalized.split('/').filter(Boolean)
const nodeModulesIndex = parts.lastIndexOf('node_modules')
const packageNameParts = packageParts(packageName)
if (nodeModulesIndex <= 0) return null
for (let i = 0; i < packageNameParts.length; i += 1) {
if (parts[nodeModulesIndex + 1 + i] !== packageNameParts[i]) return null
}
const libIndex = nodeModulesIndex - 1
if (parts[libIndex] !== 'lib') return null
const prefixParts = parts.slice(0, libIndex)
if (prefixParts.length === 0) return process.platform === 'win32' ? null : '/'
return `${normalized.startsWith('/') ? '/' : ''}${prefixParts.join('/')}`
}
async function getCommandPackagePrefixes(definition: CodingAgentDefinition, env: NodeJS.ProcessEnv): Promise<string[]> {
const commandPaths = await findCommandPaths(definition.command, env)
const prefixes = new Set<string>()
for (const commandPath of commandPaths) {
const candidates = [commandPath]
try {
candidates.push(realpathSync(commandPath))
} catch {
// Keep the unresolved command path as the fallback candidate.
}
for (const candidate of candidates) {
const prefix = getPrefixFromPackagePath(candidate, definition.packageName)
if (prefix) prefixes.add(prefix)
}
}
return [...prefixes]
}
function extractVersion(raw: string): string {
const trimmed = raw.trim()
return trimmed.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0] || trimmed.split(/\s+/)[0] || ''
}
async function getGlobalNpmBin(): Promise<string | null> {
if (typeof cachedGlobalNpmBin !== 'undefined') return cachedGlobalNpmBin
try {
const { stdout } = await runNpm(['prefix', '-g'], { timeout: 5000 })
const prefix = stdout.trim()
cachedGlobalNpmBin = prefix ? (process.platform === 'win32' ? prefix : join(prefix, 'bin')) : null
} catch {
cachedGlobalNpmBin = null
}
return cachedGlobalNpmBin
}
async function commandEnv(): Promise<NodeJS.ProcessEnv> {
const env = getCurrentNodeEnv()
const npmBin = await getGlobalNpmBin()
if (npmBin) {
const pathKey = Object.keys(env).find(key => key.toLowerCase() === 'path') || 'PATH'
const currentPath = env[pathKey] || ''
if (!currentPath.split(delimiter).includes(npmBin)) {
env[pathKey] = currentPath ? `${npmBin}${delimiter}${currentPath}` : npmBin
}
}
return env
}
export function getCodingAgentDefinitions(): CodingAgentDefinition[] {
return TOOL_DEFINITIONS.map(tool => ({ ...tool }))
}
export function getCodingAgentDefinition(id: string): CodingAgentDefinition | null {
return TOOL_DEFINITIONS.find(tool => tool.id === id) || null
}
export function getCodingAgentConfigFileDefinitions(id: string): CodingAgentConfigFileDefinition[] {
const tool = getCodingAgentDefinition(id)
if (!tool) return []
return CONFIG_FILE_DEFINITIONS[tool.id].map(file => ({
key: file.key,
path: file.path,
language: file.language,
absolutePath: expandHomePath(file.path),
}))
}
export async function getCodingAgentStatus(definition: CodingAgentDefinition): Promise<CodingAgentToolStatus> {
try {
const env = await commandEnv()
const resolvedCommand = await resolveCommandForExecution(definition.command, env)
const execution = commandExecution(resolvedCommand, ['--version'])
const { stdout, stderr } = await execFileAsync(execution.command, execution.args, {
encoding: 'utf-8',
timeout: 8000,
windowsHide: true,
env,
})
const rawVersion = `${stdout || ''}${stderr || ''}`.trim()
return {
...definition,
installed: true,
version: extractVersion(rawVersion),
rawVersion,
}
} catch (err: any) {
return {
...definition,
installed: false,
version: '',
rawVersion: '',
error: normalizeError(err),
}
}
}
export async function getCodingAgentsStatus(): Promise<CodingAgentsStatus> {
return {
tools: await Promise.all(TOOL_DEFINITIONS.map(tool => getCodingAgentStatus(tool))),
}
}
export async function installCodingAgent(id: string): Promise<CodingAgentMutationResult> {
const tool = getCodingAgentDefinition(id)
if (!tool) {
const err = new Error('Unknown coding agent')
;(err as any).status = 400
throw err
}
if (installingTools.has(tool.id)) {
const err = new Error('Install is already running')
;(err as any).status = 409
throw err
}
installingTools.add(tool.id)
try {
const env = await commandEnv()
await runNpm(['install', '-g', tool.packageName], {
timeout: 10 * 60 * 1000,
env,
})
cachedGlobalNpmBin = undefined
const status = await getCodingAgentStatus(tool)
const allStatus = await getCodingAgentsStatus()
return {
success: status.installed,
tool: status,
tools: allStatus.tools,
message: status.installed ? 'Installed' : status.error || 'Install completed but the command was not found',
}
} catch (err: any) {
const status = await getCodingAgentStatus(tool)
const allStatus = await getCodingAgentsStatus()
return {
success: false,
tool: status,
tools: allStatus.tools,
message: normalizeError(err),
code: normalizeErrorCode(err),
}
} finally {
installingTools.delete(tool.id)
}
}
export async function deleteCodingAgent(id: string): Promise<CodingAgentMutationResult> {
const tool = getCodingAgentDefinition(id)
if (!tool) {
const err = new Error('Unknown coding agent')
;(err as any).status = 400
throw err
}
if (deletingTools.has(tool.id)) {
const err = new Error('Delete is already running')
;(err as any).status = 409
throw err
}
deletingTools.add(tool.id)
try {
const env = await commandEnv()
const packagePrefixes = await getCommandPackagePrefixes(tool, env)
const uninstallArgsList = packagePrefixes.length > 0
? packagePrefixes.map(prefix => ['uninstall', '-g', '--prefix', prefix, tool.packageName])
: [['uninstall', '-g', tool.packageName]]
for (const uninstallArgs of uninstallArgsList) {
await runNpm(uninstallArgs, {
timeout: 10 * 60 * 1000,
env,
})
}
cachedGlobalNpmBin = undefined
const status = await getCodingAgentStatus(tool)
const allStatus = await getCodingAgentsStatus()
return {
success: !status.installed,
tool: status,
tools: allStatus.tools,
message: !status.installed ? 'Deleted' : 'Delete completed but the command is still available',
}
} catch (err: any) {
const status = await getCodingAgentStatus(tool)
const allStatus = await getCodingAgentsStatus()
return {
success: false,
tool: status,
tools: allStatus.tools,
message: normalizeError(err),
code: normalizeErrorCode(err),
}
} finally {
deletingTools.delete(tool.id)
}
}
export async function readCodingAgentConfigFile(id: string, key: string, scope: CodingAgentConfigScope = {}): Promise<CodingAgentConfigFileContent> {
const definition = getLiveConfigFileDefinition(id, key)
if (!definition) {
const err = new Error('Unknown coding agent config file')
;(err as any).status = 404
throw err
}
const normalizedScope = normalizeConfigScope(scope)
try {
const info = await stat(definition.absolutePath)
if (!info.isFile()) {
const err = new Error('Config path is not a file')
;(err as any).status = 400
throw err
}
if (info.size > MAX_CONFIG_FILE_SIZE) {
const err = new Error('Config file is too large to edit')
;(err as any).status = 413
throw err
}
return {
...definition,
...normalizedScope,
rootDir: dirname(definition.absolutePath),
content: await readFile(definition.absolutePath, 'utf-8'),
exists: true,
size: info.size,
}
} catch (err: any) {
if (err?.code !== 'ENOENT') throw err
return {
...definition,
...normalizedScope,
rootDir: dirname(definition.absolutePath),
content: '',
exists: false,
size: 0,
}
}
}
export async function writeCodingAgentConfigFile(id: string, key: string, content: string, scope: CodingAgentConfigScope = {}): Promise<CodingAgentConfigFileContent> {
const definition = getLiveConfigFileDefinition(id, key)
if (!definition) {
const err = new Error('Unknown coding agent config file')
;(err as any).status = 404
throw err
}
const normalizedScope = normalizeConfigScope(scope)
const buffer = Buffer.from(content || '', 'utf-8')
if (buffer.length > MAX_CONFIG_FILE_SIZE) {
const err = new Error('Config file content is too large')
;(err as any).status = 413
throw err
}
await mkdir(dirname(definition.absolutePath), { recursive: true })
await writeFile(definition.absolutePath, buffer)
return {
...definition,
...normalizedScope,
rootDir: dirname(definition.absolutePath),
content,
exists: true,
size: buffer.length,
}
}
export async function prepareCodingAgentLaunch(id: string, input: CodingAgentLaunchInput): Promise<CodingAgentLaunchResult> {
const tool = getCodingAgentDefinition(id)
if (!tool) {
const err = new Error('Unknown coding agent')
;(err as any).status = 400
throw err
}
const mode = input.mode === 'global' ? 'global' : 'scoped'
if (mode === 'global') {
const scope = normalizeConfigScope({ profile: input.profile, provider: 'global' })
const workspaceDir = getScopedWorkspaceRoot(scope)
await mkdir(workspaceDir, { recursive: true })
const shellCommand = buildLaunchShellCommand({
workspaceDir,
env: {},
command: tool.command,
args: [],
})
return {
agentId: tool.id,
mode,
profile: scope.profile,
provider: scope.provider,
model: '',
rootDir: workspaceDir,
workspaceDir,
command: tool.command,
args: [],
env: {},
shellCommand,
files: [],
}
}
const provider = normalizeScopeSegment(input.provider, 'default', 'provider')
const scope = normalizeConfigScope({ profile: input.profile, provider })
const model = String(input.model || '').trim()
if (!model) {
const err = new Error('Model is required')
;(err as any).status = 400
throw err
}
const baseUrl = String(input.baseUrl || '').trim()
const apiKey = String(input.apiKey || '').trim()
const preset = PROVIDER_PRESETS.find(item => item.value === provider)
const apiMode = normalizeLaunchApiMode(input.apiMode, preset?.api_mode || 'chat_completions')
const rootDir = getScopedConfigRoot(tool.id, scope)
const workspaceDir = getScopedWorkspaceRoot(scope)
await mkdir(rootDir, { recursive: true })
await mkdir(workspaceDir, { recursive: true })
const files: Array<{ key: string; path: string; absolutePath: string }> = []
const writeScopedFile = async (key: string, content: string) => {
const definition = getScopedConfigFileDefinition(tool.id, key, scope)
if (!definition) return
await mkdir(dirname(definition.absolutePath), { recursive: true })
await writeFile(definition.absolutePath, content, 'utf-8')
files.push({ key, path: definition.path, absolutePath: definition.absolutePath })
}
let args: string[] = []
let env: Record<string, string> = {}
if (tool.id === 'claude-code') {
const proxyTarget = baseUrl && apiKey
? registerClaudeCodeProxyTarget({ provider, model, baseUrl, apiKey, apiMode })
: null
const claudeBaseUrl = proxyTarget?.baseUrl || baseUrl
const claudeApiKey = proxyTarget?.token || apiKey
const modelName = displayNameForModel(model)
const settings = {
model,
env: {
...(claudeApiKey ? { ANTHROPIC_API_KEY: claudeApiKey } : {}),
...(claudeBaseUrl ? { ANTHROPIC_BASE_URL: claudeBaseUrl } : {}),
ANTHROPIC_MODEL: model,
ANTHROPIC_CUSTOM_MODEL_OPTION: model,
ANTHROPIC_CUSTOM_MODEL_OPTION_NAME: modelName,
ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: modelName,
ANTHROPIC_DEFAULT_SONNET_MODEL: model,
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: modelName,
ANTHROPIC_DEFAULT_OPUS_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: modelName,
},
}
await writeScopedFile('settings', `${JSON.stringify(settings, null, 2)}\n`)
await writeScopedFile('mcp', `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`)
const settingsPath = join(rootDir, 'settings.json')
const mcpPath = join(rootDir, 'mcp.json')
args = ['--settings', settingsPath, '--mcp-config', mcpPath]
} else {
if (apiMode !== 'chat_completions' && apiMode !== 'codex_responses' && apiMode !== 'anthropic_messages') {
const err = new Error('Codex launch only supports OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages providers')
;(err as any).status = 400
throw err
}
const proxyTarget = apiMode !== 'codex_responses' && baseUrl && apiKey
? registerCodexProxyTarget({ profile: scope.profile, provider, model, baseUrl, apiKey, apiMode })
: null
const codexBaseUrl = proxyTarget?.baseUrl || baseUrl
const codexApiKey = proxyTarget?.token || apiKey
const providerId = 'custom'
const catalogPath = join(rootDir, CODEX_MODEL_CATALOG_FILE)
const configToml = [
`model_catalog_json = ${JSON.stringify(catalogPath)}`,
`model_provider = ${JSON.stringify(providerId)}`,
`model = ${JSON.stringify(model)}`,
'disable_response_storage = true',
'',
`[model_providers.${providerId}]`,
`name = ${JSON.stringify(provider)}`,
...(codexBaseUrl ? [`base_url = ${JSON.stringify(codexBaseUrl)}`] : []),
'wire_api = "responses"',
'requires_openai_auth = false',
...(codexApiKey ? [`experimental_bearer_token = ${JSON.stringify(codexApiKey)}`] : []),
'',
].join('\n')
const catalog = buildCodexModelCatalog({
profile: scope.profile,
provider,
model,
presetModels: Array.isArray(preset?.models) ? preset.models : [],
})
await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`, 'utf-8')
files.push({ key: 'model_catalog', path: CODEX_MODEL_CATALOG_FILE, absolutePath: catalogPath })
await writeScopedFile('config', configToml)
await writeScopedFile('auth', `${JSON.stringify({}, null, 2)}\n`)
env = { CODEX_HOME: rootDir }
args = ['--model', model]
}
const shellCommand = buildLaunchShellCommand({
workspaceDir,
env,
command: tool.command,
args,
})
return {
agentId: tool.id,
mode,
profile: scope.profile,
provider: scope.provider,
model,
rootDir,
workspaceDir,
command: tool.command,
args,
env,
shellCommand,
files,
}
}
export async function openCodingAgentNativeTerminal(id: string, input: CodingAgentLaunchInput): Promise<CodingAgentNativeLaunchResult> {
const launch = await prepareCodingAgentLaunch(id, input)
const terminal = await openNativeTerminal(launch.shellCommand)
return {
...launch,
nativeTerminal: true,
terminal,
}
}