import Foundation #if canImport(FoundationNetworking) import FoundationNetworking #endif #if os(Windows) || os(macOS) || os(Linux) @MainActor public final class ClaudeCodeProvider: LLMProvider { public static let providerID = "claude-code" public static let mcpServerName = "luma" public let descriptor: LLMProviderDescriptor private let executablePath: String private weak var engine: Engine? private var initializedMissionSessionIDs: Set = [] public init(engine: Engine?, executablePath: String = "claude") { self.engine = engine self.executablePath = executablePath self.descriptor = LLMProviderDescriptor( id: Self.providerID, displayName: "Claude Code (subprocess)", capabilities: LLMProviderCapabilities( supported: engine != nil ? [.streaming, .promptCaching, .toolUse] : [.streaming, .promptCaching], reasoningEffortOptions: ["auto", "low", "medium", "high", "xhigh", "max"], defaultReasoningEffort: "auto" ), defaultModelID: "default", summarizationModelID: "haiku", defaultBaseURL: URL(string: "claude://localhost")! ) } nonisolated public func suggestedModels(apiKey: String?, baseURL: URL?) async throws -> [LLMModelInfo] { [ LLMModelInfo(id: "default", displayName: "Default (Claude Code's choice)", contextWindow: 200_000, maxOutput: 8_192), LLMModelInfo(id: "sonnet", displayName: "Sonnet", contextWindow: 200_000, maxOutput: 16_384), LLMModelInfo(id: "opus", displayName: "Opus", contextWindow: 200_000, maxOutput: 16_384), LLMModelInfo(id: "haiku", displayName: "Haiku", contextWindow: 200_000, maxOutput: 8_192), ] } nonisolated public func streamTurn( _ request: LLMTurnRequest, apiKey: String?, baseURL: URL? ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in let process = ChildProcess() let mcpHandle = MCPServerHandle() let work = Task { @MainActor in do { try await self.drive(process: process, mcpHandle: mcpHandle, request: request, continuation: continuation) continuation.finish() } catch is CancellationError { continuation.finish(throwing: LLMProviderError.cancelled) } catch { continuation.finish(throwing: error) } await mcpHandle.stop() } continuation.onTermination = { _ in work.cancel() if process.isRunning { process.terminate() } } } } private func drive( process: ChildProcess, mcpHandle: MCPServerHandle, request: LLMTurnRequest, continuation: AsyncThrowingStream.Continuation ) async throws { let sessionPlan = makeSessionPlan(for: request) let prompt = sessionPlan.prompt let systemText = renderSystemPrompt(request.systemBlocks) var arguments: [String] = [ executablePath, "--print", prompt, "--output-format", "stream-json", "--include-partial-messages", "--verbose", "--exclude-dynamic-system-prompt-sections", ] if let sessionID = sessionPlan.sessionID { arguments.append(contentsOf: ["--session-id", sessionID.uuidString]) } if !systemText.isEmpty { arguments.append(contentsOf: ["--append-system-prompt", systemText]) } if request.modelID != "default", !request.modelID.isEmpty { arguments.append(contentsOf: ["--model", request.modelID]) } if let effort = request.reasoningEffort, effort != "auto", !effort.isEmpty { arguments.append(contentsOf: ["--effort", effort]) } #if canImport(Network) || canImport(CSoup) if let mission = request.mission, let engine, !request.tools.isEmpty { let toolNames = request.tools.map(\.name) let server = MCPServer(engine: engine, mission: mission, toolNames: toolNames) server.onToolStarted = { name, args in continuation.yield(.textDelta("\n[→ \(name)\(formatArgs(args))]\n")) } server.onToolFinished = { _, result in continuation.yield(.textDelta("[← \(result.summary)]\n")) } let url = try await server.start() engine.registerActiveMCPServer(server, for: mission.id) await mcpHandle.set(server, missionID: mission.id, engine: engine) let mcpConfig: [String: Any] = [ "mcpServers": [ Self.mcpServerName: [ "type": "http", "url": url.absoluteString, "headers": ["Authorization": "Bearer \(server.bearerToken)"], ], ], ] let mcpConfigData = (try? JSONSerialization.data(withJSONObject: mcpConfig)) ?? Data("{}".utf8) let mcpConfigString = String(data: mcpConfigData, encoding: .utf8) ?? "{}" arguments.append(contentsOf: ["--mcp-config", mcpConfigString, "--strict-mcp-config"]) let allowList = toolNames.map { "mcp__\(Self.mcpServerName)__\($0)" }.joined(separator: ",") arguments.append(contentsOf: ["--allowed-tools", allowList]) } else { arguments.append(contentsOf: ["--allowed-tools", ""]) } #else _ = mcpHandle arguments.append(contentsOf: ["--allowed-tools", ""]) #endif process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = arguments process.environment = augmentedEnvironment() let stdoutPipe = Pipe() let stderrPipe = Pipe() let stdinPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe process.standardInput = stdinPipe do { try process.run() } catch { throw LLMProviderError.requestFailed(status: -1, message: "could not spawn claude: \(error.localizedDescription)") } try? stdinPipe.fileHandleForWriting.close() let stdoutHandle = stdoutPipe.fileHandleForReading var buffer = Data() var streamedText = "" var assistantText = "" var assembledUsage = LLMUsage.zero var stopReason = LLMStopReason.endTurn var errorDetail: String? for await chunk in pipeChunks(stdoutHandle) { try Task.checkCancellation() buffer.append(chunk) while let nl = buffer.firstIndex(of: 0x0a) { let lineData = buffer[...Continuation, streamedText: inout String, assistantText: inout String, assembledUsage: inout LLMUsage, stopReason: inout LLMStopReason, errorDetail: inout String? ) { let type = event["type"] as? String ?? "" switch type { case "system": if (event["subtype"] as? String) == "api_retry" { let attempt = event["attempt"] as? Int ?? 0 let maxRetries = event["max_retries"] as? Int ?? 0 let reason = event["error"] as? String ?? "transient error" errorDetail = "model \(reason) after \(maxRetries) retries" continuation.yield(.textDelta("\n[model \(reason), retrying \(attempt)/\(maxRetries)…]\n")) } case "stream_event": guard let payload = event["event"] as? [String: Any] else { return } if (payload["type"] as? String) == "content_block_delta", let delta = payload["delta"] as? [String: Any], (delta["type"] as? String) == "text_delta", let text = delta["text"] as? String, !text.isEmpty { streamedText.append(text) continuation.yield(.textDelta(text)) } case "assistant": guard let message = event["message"] as? [String: Any], let content = message["content"] as? [[String: Any]] else { return } for block in content { if (block["type"] as? String) == "text", let text = block["text"] as? String, !text.isEmpty { assistantText.append(text) } } case "result": if let usage = event["usage"] as? [String: Any] { assembledUsage = decodeUsage(usage, prior: assembledUsage) continuation.yield(.usage(assembledUsage)) } switch event["subtype"] as? String { case "success": stopReason = .endTurn case "error_max_turns": stopReason = .maxTokens case "error_during_execution", "error": stopReason = .error if let detail = event["result"] as? String, !detail.isEmpty { errorDetail = detail } default: stopReason = .endTurn } default: break } } private func renderConversation(_ messages: [LLMMessage]) -> String { var lines: [String] = [] for message in messages { let speaker = message.role == .user ? "User" : "Assistant" let text = message.blocks.compactMap { block -> String? in switch block.content { case .text(let t): return t case .toolResult(_, let content, _, _): return "[tool result]\n\(content)" default: return nil } }.joined(separator: "\n") if !text.isEmpty { lines.append("\(speaker): \(text)") } } return lines.joined(separator: "\n\n") } private struct ClaudeSessionPlan { var prompt: String var sessionID: UUID? } private func makeSessionPlan(for request: LLMTurnRequest) -> ClaudeSessionPlan { guard let missionID = request.mission?.id else { return ClaudeSessionPlan(prompt: renderConversation(request.messages), sessionID: nil) } if initializedMissionSessionIDs.contains(missionID), let latest = latestUserMessage(in: request.messages) { return ClaudeSessionPlan(prompt: renderConversation([latest]), sessionID: missionID) } if request.messages.count <= 1 { return ClaudeSessionPlan(prompt: renderConversation(request.messages), sessionID: missionID) } return ClaudeSessionPlan(prompt: renderConversation(request.messages), sessionID: nil) } private func latestUserMessage(in messages: [LLMMessage]) -> LLMMessage? { messages.reversed().first { $0.role == .user } } private func renderSystemPrompt(_ blocks: [LLMContentBlock]) -> String { blocks.compactMap { block -> String? in if case .text(let t) = block.content { return t } return nil }.joined(separator: "\n\n") } private func augmentedEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment let inherited = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" let candidates = [ NSString(string: "~/.local/bin").expandingTildeInPath, NSString(string: "~/.npm-global/bin").expandingTildeInPath, "/opt/homebrew/bin", "/usr/local/bin", ] let inheritedSegments = inherited.split(separator: ":").map(String.init) var seen = Set(inheritedSegments) var augmented = candidates.filter { !seen.contains($0) } seen.formUnion(augmented) augmented.append(contentsOf: inheritedSegments) env["PATH"] = augmented.joined(separator: ":") return env } private func decodeUsage(_ obj: [String: Any], prior: LLMUsage) -> LLMUsage { var u = prior if let v = obj["input_tokens"] as? Int { u.inputTokens = v } if let v = obj["output_tokens"] as? Int { u.outputTokens = v } if let v = obj["cache_read_input_tokens"] as? Int { u.cacheReadTokens = v } if let v = obj["cache_creation_input_tokens"] as? Int { u.cacheCreateTokens = v } return u } } private actor MCPServerHandle { #if canImport(Network) || canImport(CSoup) private var server: MCPServer? private var missionID: UUID? private weak var engine: Engine? func set(_ server: MCPServer, missionID: UUID, engine: Engine) { self.server = server self.missionID = missionID self.engine = engine } func stop() async { guard let server = server else { return } if let missionID, let engine { await engine.unregisterActiveMCPServer(for: missionID) } await MainActor.run { server.stop() } self.server = nil self.missionID = nil self.engine = nil } #else func stop() async {} #endif } private func pipeChunks(_ handle: FileHandle) -> AsyncStream { AsyncStream { continuation in handle.readabilityHandler = { fh in let data = fh.availableData if data.isEmpty { continuation.finish() } else { continuation.yield(data) } } continuation.onTermination = { _ in handle.readabilityHandler = nil } } } private func formatArgs(_ args: [String: Any]) -> String { if args.isEmpty { return "()" } let pairs = args.map { key, value -> String in if let s = value as? String, s.count <= 60 { return "\(key)=\"\(s)\"" } return "\(key)=…" } return "(" + pairs.joined(separator: ", ") + ")" } #endif