package cc.unitmesh.devins.ui.compose.agent.acp import cc.unitmesh.agent.acp.AcpClient import cc.unitmesh.agent.claude.ClaudeCodeClient import cc.unitmesh.agent.render.CodingAgentRenderer import cc.unitmesh.config.AcpAgentConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.withContext import kotlinx.io.asSink import kotlinx.io.asSource import java.io.File /** * Create an ACP connection. * Always returns [JvmAcpConnection] for standard ACP protocol communication. */ actual fun createAcpConnection(): AcpConnection? = JvmAcpConnection() /** * Create the appropriate connection for the given agent config. * - Claude Code: uses [JvmClaudeCodeConnection] with direct stream-json protocol. * - Auggie: uses [JvmAcpConnection] with standard ACP JSON-RPC. * - All others (Kimi, Gemini, etc.): uses [JvmAcpConnection] with standard ACP JSON-RPC. * * Supported agents: * - **Auggie**: Augment Code's AI agent (https://docs.augmentcode.com/cli/acp/agent) * - **Claude Code**: Anthropic's Claude Code agent * - **Kimi**: Chinese AI agent with strong coding capabilities * - **Gemini**: Google's Gemini agent * * @see Issue #536 */ actual fun createConnectionForAgent(config: AcpAgentConfig): AcpConnection? { return if (looksLikeClaude(config.command)) { JvmClaudeCodeConnection() } else { JvmAcpConnection() } } actual fun isAcpSupported(): Boolean = true /** * JVM implementation of AcpConnection. * Spawns the agent as a child process and communicates via ACP (JSON-RPC over stdio). * * Uses [AcpClient] from mpp-core which handles the ACP protocol details. * Events are streamed directly to the provided [CodingAgentRenderer] via * [AcpClient.promptAndRender], allowing seamless integration with ComposeRenderer. * * Supports all standard ACP agents including: * - Auggie (https://docs.augmentcode.com/cli/acp/agent) * - Kimi CLI (with automatic --work-dir injection) * - Gemini CLI * - Any other ACP-compliant agent */ class JvmAcpConnection : AcpConnection { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var process: Process? = null private var acpClient: AcpClient? = null override val isConnected: Boolean get() = acpClient?.isConnected == true override suspend fun connect( config: AcpAgentConfig, cwd: String ) { withContext(Dispatchers.IO) { val effectiveCwd = cwd.ifBlank { System.getProperty("user.dir") ?: cwd } // Build command (and inject workdir for kimi if not provided) val args = config.getArgsList().toMutableList() if (looksLikeKimi(config.command) && !hasWorkDirArg(args)) { // Kimi CLI supports `--work-dir ` to pin its workspace. // This is critical because Kimi's shell actions may run in separate subprocesses, // so `cd`-based prompts are unreliable. args.addAll(0, listOf("--work-dir", effectiveCwd)) } val commandList = mutableListOf(config.command).apply { addAll(args) } println("[ACP] Spawning agent: ${commandList.joinToString(" ")}") // Spawn process val pb = ProcessBuilder(commandList) pb.directory(File(effectiveCwd)) pb.redirectErrorStream(false) // Add environment variables config.getEnvMap().forEach { (key, value) -> pb.environment()[key] = value } // Provide workspace hints for agents that rely on env vars. pb.environment()["PWD"] = effectiveCwd pb.environment()["AUTODEV_WORKSPACE"] = effectiveCwd val proc = pb.start() process = proc // Create ACP client using the process's stdio val input = proc.inputStream.asSource() val output = proc.outputStream.asSink() val client = AcpClient( coroutineScope = scope, input = input, output = output, clientName = "autodev-xiuper-compose", clientVersion = "3.0.0", cwd = effectiveCwd, agentName = config.name.ifBlank { "acp-agent" }, enableLogging = true ) client.connect() acpClient = client println("[ACP] Connected to agent successfully") } } override suspend fun prompt(text: String, renderer: CodingAgentRenderer): String { val client = acpClient ?: throw IllegalStateException("ACP client not connected") withContext(Dispatchers.IO) { client.promptAndRender(text, renderer) } return "completed" } override suspend fun cancel() { try { acpClient?.cancel() } catch (e: Exception) { println("[ACP] Cancel failed: ${e.message}") } } override suspend fun disconnect() { try { acpClient?.disconnect() } catch (_: Exception) {} acpClient = null try { process?.destroyForcibly() } catch (_: Exception) {} process = null println("[ACP] Disconnected") } private fun looksLikeKimi(command: String): Boolean { val base = command.substringAfterLast('/').substringAfterLast('\\') return base.equals("kimi", ignoreCase = true) || base.equals("kimi.exe", ignoreCase = true) } private fun hasWorkDirArg(args: List): Boolean { // Accept both "--work-dir" and "--workdir" just in case. return args.any { it == "--work-dir" || it == "--workdir" } } } /** * JVM implementation for Claude Code using direct stream-json protocol. * * Unlike ACP agents, Claude Code uses its own JSON streaming protocol: * - Launches `claude -p --output-format stream-json --input-format stream-json` * - Reads JSON lines from stdout (system, stream_event, assistant, result messages) * - Writes JSON lines to stdin (user messages) * * This is a Kotlin adaptation of the approach used in: * - IDEA ml-llm: [ClaudeCodeProcessHandler] + [ClaudeCodeLongRunningSession] * - zed-industries/claude-code-acp: TypeScript ACP adapter * * @see Issue #536 */ class JvmClaudeCodeConnection : AcpConnection { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var client: ClaudeCodeClient? = null override val isConnected: Boolean get() = client?.isConnected == true override suspend fun connect(config: AcpAgentConfig, cwd: String) { withContext(Dispatchers.IO) { val effectiveCwd = cwd.ifBlank { System.getProperty("user.dir") ?: cwd } // Parse extra args from config (skip -p, --output-format, --input-format which we set) val extraArgs = config.getArgsList().toMutableList() // Extract model and permission-mode from args if present var model: String? = null var permissionMode: String? = null val filteredArgs = mutableListOf() val iter = extraArgs.iterator() while (iter.hasNext()) { val arg = iter.next() when (arg) { "--model" -> { if (iter.hasNext()) model = iter.next() } "--permission-mode" -> { if (iter.hasNext()) permissionMode = iter.next() } // Skip flags we add ourselves "-p", "--print", "--output-format", "--input-format", "--verbose", "--include-partial-messages" -> {} "stream-json" -> {} // value of --output-format or --input-format else -> filteredArgs.add(arg) } } val claudeClient = ClaudeCodeClient( scope = scope, binaryPath = config.command, workingDirectory = effectiveCwd, agentName = config.name.ifBlank { "Claude Code" }, enableLogging = true, model = model, permissionMode = permissionMode, additionalArgs = filteredArgs, envVars = config.getEnvMap(), ) claudeClient.start() client = claudeClient println("[ClaudeCode] Connected to Claude Code at ${config.command}") } } override suspend fun prompt(text: String, renderer: CodingAgentRenderer): String { val c = client ?: throw IllegalStateException("ClaudeCodeClient not connected") c.promptAndRender(text, renderer) return "completed" } override suspend fun cancel() { // Claude Code in -p mode doesn't have a cancel mechanism via stdin. // The only way is to kill the process (which disconnect() does). println("[ClaudeCode] Cancel requested - stopping process") disconnect() } override suspend fun disconnect() { try { client?.stop() } catch (_: Exception) {} client = null println("[ClaudeCode] Disconnected") } } /** * Check if a command path looks like the Claude Code CLI. */ private fun looksLikeClaude(command: String): Boolean { val base = command.substringAfterLast('/').substringAfterLast('\\').lowercase() return base == "claude" || base == "claude.exe" || base == "claude-code" || base == "claude-code.exe" }