package cc.unitmesh.agent.claude import cc.unitmesh.agent.render.CodingAgentRenderer import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.* import kotlinx.serialization.json.* import java.io.* import java.text.SimpleDateFormat import java.util.* private val logger = KotlinLogging.logger("ClaudeCodeClient") /** * Kotlin client for Claude Code binary. * * Launches `claude -p --output-format stream-json --input-format stream-json` * and communicates via JSON lines over stdio. * * Reference implementations: * - IDEA ml-llm: ClaudeCodeProcessHandler + ClaudeCodeLongRunningSession * - zed-industries/claude-code-acp: TypeScript ACP adapter around Claude Agent SDK * * @see Issue #538 */ class ClaudeCodeClient( private val scope: CoroutineScope, private val binaryPath: String, private val workingDirectory: String, private val agentName: String = "Claude Code", private val enableLogging: Boolean = true, private val model: String? = null, private val permissionMode: String? = null, private val additionalArgs: List = emptyList(), private val envVars: Map = emptyMap(), ) { private var process: Process? = null private var writer: BufferedWriter? = null private var reader: BufferedReader? = null private var readerJob: Job? = null private var logWriter: BufferedWriter? = null private var sessionId: String? = null private val toolUseNames = mutableMapOf() // Track which tool IDs have been rendered to avoid duplicates private val renderedToolIds = mutableSetOf() // Track tool_use input data by toolId for rendering private val toolUseInputs = mutableMapOf>() val isConnected: Boolean get() = process?.isAlive == true /** * Start the Claude Code process with stream-json mode. */ fun start() { val cmd = mutableListOf(binaryPath, "-p") cmd.addAll(listOf("--output-format", "stream-json")) cmd.addAll(listOf("--input-format", "stream-json")) cmd.add("--verbose") // Required for streaming: without this flag, Claude Code only emits the final `result` // message. With it, we get `stream_event` messages with text/thinking/tool_use deltas. cmd.add("--include-partial-messages") model?.let { cmd.addAll(listOf("--model", it)) } // Default to acceptEdits if not specified - this auto-approves Edit/Write operations // while still prompting for dangerous operations like Bash. For full automation, // users can configure permission-mode=bypassPermissions in config.yaml. val effectivePermissionMode = permissionMode ?: "acceptEdits" cmd.addAll(listOf("--permission-mode", effectivePermissionMode)) // Disable AskUserQuestion tool - not supported in stream-json mode without // interactive CLI capabilities. Claude Code's AskUserQuestion expects terminal // interaction which doesn't work well with the stream-json protocol. cmd.addAll(listOf("--disallowed-tools", "AskUserQuestion")) cmd.addAll(additionalArgs) logger.info { "[ClaudeCode] Starting: ${cmd.joinToString(" ")}" } val pb = ProcessBuilder(cmd) pb.directory(File(workingDirectory)) pb.redirectErrorStream(false) envVars.forEach { (k, v) -> pb.environment()[k] = v } pb.environment()["PWD"] = workingDirectory pb.environment()["AUTODEV_WORKSPACE"] = workingDirectory val proc = pb.start() process = proc writer = proc.outputStream.bufferedWriter() reader = proc.inputStream.bufferedReader() if (enableLogging) { initLogging() } // Drain stderr asynchronously to prevent blocking scope.launch(Dispatchers.IO) { try { proc.errorStream.bufferedReader().use { err -> err.lineSequence().forEach { line -> logger.debug { "[ClaudeCode stderr] $line" } } } } catch (_: Exception) { } } logger.info { "[ClaudeCode] Process started (pid=${proc.pid()})" } } /** * Send a prompt and render the response through the given [renderer]. * * Claude Code in stream-json mode emits messages in this order: * 1. system init (on first prompt only) * 2. stream_event (content_block_start/delta/stop, message_start/delta/stop) * 3. assistant (assembled message with all content blocks) * 4. result (success/error) * * This blocks (suspends) until Claude finishes its response (receives a `result` message). */ suspend fun promptAndRender(text: String, renderer: CodingAgentRenderer) { val proc = process ?: throw IllegalStateException("ClaudeCodeClient not started") val w = writer ?: throw IllegalStateException("Writer not available") val r = reader ?: throw IllegalStateException("Reader not available") // Send user message val userJson = buildClaudeUserInput(text, sessionId) logLine(">>> $userJson") withContext(Dispatchers.IO) { w.write(userJson) w.newLine() w.flush() } renderer.renderLLMResponseStart() var inThinking = false var inText = false var hasRenderedStreamContent = false val startTime = System.currentTimeMillis() var toolCount = 0 // Track tool input JSON as it streams in via input_json_delta val pendingToolInputs = mutableMapOf() // Read response lines until we get a result message withContext(Dispatchers.IO) { while (proc.isAlive) { val line = try { r.readLine() } catch (_: IOException) { null } if (line == null) break logLine(line) val msg = parseClaudeOutputLine(line) ?: continue when (msg.type) { ClaudeMessageType.SYSTEM -> { if (msg.subtype == "init") { sessionId = msg.sessionId logger.info { "[ClaudeCode] Initialized (session=${sessionId})" } } } ClaudeMessageType.STREAM_EVENT -> { val event = msg.streamEvent ?: continue when (event.type) { "content_block_start" -> { val block = event.contentBlock ?: continue when (block.type) { "thinking" -> { inThinking = true renderer.renderThinkingChunk("", isStart = true) } "text" -> { inText = true } "tool_use" -> { val toolId = block.id ?: "" val toolName = block.name ?: "unknown" val index = event.index ?: -1 toolUseNames[toolId] = toolName // Initialize StringBuilder for this tool's input JSON if (index >= 0) { pendingToolInputs[index] = StringBuilder() } // DO NOT render yet - wait for assistant message with full params // This avoids showing "Bash" with no parameters } } } "content_block_delta" -> { val delta = event.delta ?: continue val index = event.index ?: -1 when (delta.type) { "thinking_delta" -> { delta.thinking?.let { hasRenderedStreamContent = true renderer.renderThinkingChunk(it) } } "text_delta" -> { delta.text?.let { hasRenderedStreamContent = true renderer.renderLLMResponseChunk(it) } } "input_json_delta" -> { // Accumulate tool input JSON as it streams in delta.partialJson?.let { jsonChunk -> if (index >= 0) { pendingToolInputs.getOrPut(index) { StringBuilder() } .append(jsonChunk) } } } } } "content_block_stop" -> { if (inThinking) { inThinking = false renderer.renderThinkingChunk("", isEnd = true) } if (inText) { inText = false } } "message_start", "message_delta", "message_stop" -> { // Lifecycle events - skip } } } ClaudeMessageType.ASSISTANT -> { // Assistant message contains assembled content blocks. // For tool_use: render the tool call with full parameters. // For text/thinking: already rendered by stream_event, skip. for (c in msg.content) { when (c.type) { "tool_use" -> { val toolId = c.id ?: "" val toolName = c.name ?: "unknown" toolUseNames[toolId] = toolName // Parse tool input to a readable map val inputMap = try { c.input?.let { parseJsonToMap(it) } ?: emptyMap() } catch (e: Exception) { logger.warn { "[ClaudeCode] Failed to parse tool input for $toolName: ${e.message}" } emptyMap() } // Store for later lookup when tool_result arrives toolUseInputs[toolId] = inputMap // Only render if not already rendered if (!renderedToolIds.contains(toolId)) { toolCount++ // Map Claude Code tool names and params to our internal format val mappedName = mapClaudeToolName(toolName) val mappedParams = mapClaudeParams(toolName, inputMap) renderer.renderToolCallWithParams(mappedName, mappedParams) renderedToolIds.add(toolId) } } // text/thinking already handled by stream_event } } } ClaudeMessageType.USER -> { // User messages contain tool_result blocks (Claude executed the tool // and wraps the result in a user message for the next turn). // We must handle these to mark tool calls as COMPLETED. for (c in msg.content) { when (c.type) { "tool_result" -> { val toolId = c.toolUseId ?: "" val toolName = toolUseNames[toolId] ?: "unknown" val mappedName = mapClaudeToolName(toolName) val isErr = c.isError == true val output = extractToolResultText(c) // Truncate output for display (keep full for expanded view) val summary = if (output.length > 200) { output.take(200) + "..." } else { output } renderer.renderToolResult(mappedName, !isErr, summary, output) } // "text" in user messages is just echo/context - skip } } } ClaudeMessageType.RESULT -> { // The result message contains the final text in `result` field. // If streaming was active (stream_event messages were received), // the text was already rendered incrementally. If not, render it now. val resultText = msg.result ?: "" if (resultText.isNotEmpty() && !hasRenderedStreamContent) { // No stream events were received -- render the full result text renderer.renderLLMResponseChunk(resultText) } renderer.renderLLMResponseEnd() val elapsed = System.currentTimeMillis() - startTime val success = !msg.isError renderer.renderFinalResult( success = success, message = "Claude Code finished: ${msg.subtype ?: "unknown"}" + if (msg.isError) " (error)" else "", iterations = 0 ) renderer.renderTaskComplete(elapsed, toolCount) return@withContext } ClaudeMessageType.UNKNOWN -> { logger.debug { "[ClaudeCode] Unknown message type: ${msg.rawJson}" } } } } } // If we get here without a result, the process may have exited if (process?.isAlive != true) { renderer.renderLLMResponseEnd() renderer.renderError("Claude Code process exited unexpectedly (exit code: ${process?.exitValue()})") } } /** * Kill the process. */ fun stop() { try { writer?.close() } catch (_: Exception) {} try { reader?.close() } catch (_: Exception) {} try { process?.destroyForcibly() } catch (_: Exception) {} try { logWriter?.close() } catch (_: Exception) {} writer = null reader = null process = null logWriter = null sessionId = null toolUseNames.clear() toolUseInputs.clear() renderedToolIds.clear() logger.info { "[ClaudeCode] Stopped" } } // ─── Internals ───────────────────────────────────────────────── /** * Map Claude Code tool names to our internal ToolType names. * Claude Code uses different names (e.g., "Bash" vs our "shell", "Read" vs "read_file"). * This mapping enables proper formatting and icon display in ComposeRenderer. */ private fun mapClaudeToolName(claudeToolName: String): String { return when (claudeToolName) { "Bash" -> "shell" // Match ToolType names in mpp-core (hyphenated) "Read" -> "read-file" "Write" -> "write-file" "Edit" -> "edit-file" "Glob" -> "glob" "Grep" -> "grep" "Task" -> "Task" "WebFetch" -> "WebFetch" "WebSearch" -> "WebSearch" "TodoWrite" -> "TodoWrite" "LS" -> "LS" else -> claudeToolName } } /** * Map Claude Code param keys to our internal param keys. * E.g., Claude uses "file_path" but our formatToolCallDisplay expects "path". */ private fun mapClaudeParams(claudeToolName: String, params: Map): Map { return when (claudeToolName) { "Read", "Write", "Edit" -> { // Map "file_path" -> "path" for compatibility with RendererUtils.formatToolCallDisplay val mapped = params.toMutableMap() params["file_path"]?.let { mapped["path"] = it } mapped } "Bash" -> { // Map "command" + "description" val mapped = params.toMutableMap() // "command" already matches our internal key mapped } else -> params } } /** * Parse JsonElement to Map for rendering. * Handles both JsonObject (already parsed) and String (needs parsing). */ private fun parseJsonToMap(input: Any?): Map { return try { when (input) { is JsonElement -> { // Already a JsonElement from kotlinx.serialization if (input is JsonObject) { input.entries.associate { (key, value) -> key to when (value) { is JsonPrimitive -> value.contentOrNull ?: value.toString() is JsonArray -> value.toString() is JsonObject -> value.toString() else -> value.toString() } } } else { emptyMap() } } is String -> { // String that needs parsing val json = Json { ignoreUnknownKeys = true } val element = json.parseToJsonElement(input) parseJsonToMap(element) // Recursive call with parsed element } else -> emptyMap() } } catch (e: Exception) { logger.warn { "[ClaudeCode] Failed to parse tool input: ${e.message}" } emptyMap() } } private fun extractToolResultText(content: ClaudeContent): String { val c = content.content return when (c) { is JsonPrimitive -> c.contentOrNull ?: "" else -> c?.toString() ?: "" } } private fun initLogging() { try { val logDir = File(System.getProperty("user.home"), ".autodev/acp-logs") logDir.mkdirs() val ts = SimpleDateFormat("yyyyMMdd-HHmmss").format(Date()) val logFile = File(logDir, "ClaudeCode_$ts.jsonl") logWriter = logFile.bufferedWriter() logger.info { "[ClaudeCode] Logging to ${logFile.absolutePath}" } } catch (e: Exception) { logger.warn { "[ClaudeCode] Failed to init logging: ${e.message}" } } } private fun logLine(line: String) { try { logWriter?.apply { write(line) newLine() flush() } } catch (_: Exception) {} } }