From 778b0e40d720c91ee18a002bc10da142edcbefa0 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 14 May 2026 18:52:11 +0800 Subject: [PATCH 1/2] fix: codex issues --- RxCode/App/AppState.swift | 29 ++------ RxCode/Services/CodexAppServer.swift | 74 ++++++++++++++++++- RxCode/Views/MainView.swift | 4 +- .../Permission/PermissionQueueBanner.swift | 2 +- RxCode/Views/SettingsView.swift | 23 +----- 5 files changed, 82 insertions(+), 50 deletions(-) diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 2985acdf..dd6d7ce7 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -79,18 +79,14 @@ struct SessionStreamState { enum SummarizationProvider: String, CaseIterable, Identifiable { case selectedClient - case claudeCode - case codex case openAI var id: String { rawValue } var displayName: String { switch self { - case .selectedClient: return "Selected Client" - case .claudeCode: return "Claude Code" - case .codex: return "Codex" - case .openAI: return "OpenAI" + case .selectedClient: return "Thread Model" + case .openAI: return "OpenAI-Compatible Endpoint" } } } @@ -966,16 +962,14 @@ final class AppState { } /// Derive a UI status for the chat row in the project sidebar. - /// Phase 1 wires only the streaming + pending-permission signals; Phase 2 will - /// add `awaitingPermission` partitioning by session and explicit error tracking. func chatStatus(forSessionId id: String, in window: WindowState) -> ChatStatus { + if window.pendingPermissions.contains(where: { $0.sessionId == id }) { + return .awaitingPermission + } if let state = sessionStates[id] { if state.isStreaming { return .streaming } if state.hasUncheckedCompletion { return .done } } - if window.pendingPermissions.contains(where: { $0.sessionId == id }) { - return .awaitingPermission - } return .idle } @@ -1783,6 +1777,7 @@ final class AppState { threadId: cliSessionId, model: model, permissionMode: registerMode, + planMode: permissionMode == .plan, permissionServer: permission ) } @@ -3059,18 +3054,6 @@ final class AppState { let provider = summary.agentProvider let model = summary.model ?? selectedSummarizationModel(for: provider) return await generateSessionTitle(firstUserMessage: firstUserMessage, provider: provider, model: model) - case .claudeCode: - return await generateSessionTitle( - firstUserMessage: firstUserMessage, - provider: .claudeCode, - model: selectedSummarizationModel(for: .claudeCode) - ) - case .codex: - return await generateSessionTitle( - firstUserMessage: firstUserMessage, - provider: .codex, - model: selectedSummarizationModel(for: .codex) - ) case .openAI: guard !openAISummarizationModel.isEmpty else { return nil } return await openAISummarization.generateSessionTitle( diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift index 485000ca..6089cf38 100644 --- a/RxCode/Services/CodexAppServer.swift +++ b/RxCode/Services/CodexAppServer.swift @@ -179,7 +179,7 @@ actor CodexAppServer { activeThreadId = Self.threadId(from: result) ?? UUID().uuidString } if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model)), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: .default, planMode: false)), to: handles.stdin) turnStarted = true } default: @@ -275,6 +275,7 @@ actor CodexAppServer { threadId: String?, model: String?, permissionMode: PermissionMode, + planMode: Bool, permissionServer: PermissionServer ) -> AsyncStream { AsyncStream { continuation in @@ -290,6 +291,7 @@ actor CodexAppServer { threadId: threadId, model: model, permissionMode: permissionMode, + planMode: planMode, permissionServer: permissionServer, continuation: continuation ) @@ -326,6 +328,7 @@ actor CodexAppServer { threadId: String?, model: String?, permissionMode: PermissionMode, + planMode: Bool, permissionServer: PermissionServer, continuation: AsyncStream.Continuation ) async { @@ -349,7 +352,10 @@ actor CodexAppServer { case "1": try Self.writeJSONLine(Self.notification(method: "initialized", params: [:]), to: handles.stdin) let method = activeThreadId == nil ? "thread/start" : "thread/resume" - try Self.writeJSONLine(Self.request(id: 2, method: method, params: threadParams(threadId: activeThreadId, cwd: cwd)), to: handles.stdin) + let params = method == "thread/start" + ? threadStartParams(threadId: activeThreadId, cwd: cwd, permissionMode: permissionMode, planMode: planMode) + : threadParams(threadId: activeThreadId, cwd: cwd) + try Self.writeJSONLine(Self.request(id: 2, method: method, params: params), to: handles.stdin) case "2": if let result = object["result"] { activeThreadId = Self.threadId(from: result) ?? activeThreadId ?? UUID().uuidString @@ -362,7 +368,7 @@ actor CodexAppServer { ))) } if let activeThreadId, !turnStarted { - try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model)), to: handles.stdin) + try Self.writeJSONLine(Self.request(id: 3, method: "turn/start", params: turnParams(threadId: activeThreadId, prompt: prompt, cwd: cwd, model: model, permissionMode: permissionMode, planMode: planMode)), to: handles.stdin) turnStarted = true } case "3": @@ -381,6 +387,7 @@ actor CodexAppServer { object: object, activeThreadId: activeThreadId, permissionMode: permissionMode, + planMode: planMode, permissionServer: permissionServer, stdin: handles.stdin ) @@ -508,6 +515,7 @@ actor CodexAppServer { object: [String: JSONValue], activeThreadId: String?, permissionMode: PermissionMode, + planMode: Bool, permissionServer: PermissionServer, stdin: FileHandle ) async throws { @@ -516,6 +524,15 @@ actor CodexAppServer { case "item/commandExecution/requestApproval", "item/fileChange/requestApproval", "request/approval": + // Belt-and-suspenders: even though we set approvalPolicy on thread/start, + // older codex versions may still escalate. Auto-accept when the user picked + // .auto or .bypassPermissions and we're not in plan mode. + if !planMode, permissionMode == .auto || permissionMode == .bypassPermissions { + try Self.writeJSONLine(Self.response(id: requestId, result: [ + "decision": .string("accept") + ]), to: stdin) + return + } let toolUseId = Self.firstString(in: params, keys: ["itemId", "callId", "id"]) ?? requestId let command = Self.firstString(in: params, keys: ["command", "cmd"]) let toolName = command == nil ? "Edit" : "Bash" @@ -571,7 +588,18 @@ actor CodexAppServer { return params } - private func turnParams(threadId: String, prompt: String, cwd: String, model: String?) -> [String: JSONValue] { + private func threadStartParams(threadId: String?, cwd: String, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { + var params: [String: JSONValue] = ["cwd": .string(cwd)] + if let threadId { params["threadId"] = .string(threadId) } + params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) + params["sandbox"] = .string(Self.codexSandboxMode(permissionMode: permissionMode, planMode: planMode)) + if planMode { + params["developerInstructions"] = .string(Self.planModeInstructions) + } + return params + } + + private func turnParams(threadId: String, prompt: String, cwd: String, model: String?, permissionMode: PermissionMode, planMode: Bool) -> [String: JSONValue] { var params: [String: JSONValue] = [ "threadId": .string(threadId), "cwd": .string(cwd), @@ -580,9 +608,47 @@ actor CodexAppServer { ]) ] if let model { params["model"] = .string(model) } + params["approvalPolicy"] = .string(Self.codexApprovalPolicy(permissionMode: permissionMode, planMode: planMode)) + params["sandboxPolicy"] = Self.codexSandboxPolicy(permissionMode: permissionMode, planMode: planMode) return params } + private static let planModeInstructions = """ + Plan mode is enabled. Produce a clear, step-by-step plan using the update_plan tool. \ + Do not modify files; do not run commands that mutate state. \ + Read-only inspection is allowed. End with a concise summary of the proposed plan and \ + wait for the user to disable plan mode before making changes. + """ + + private static func codexApprovalPolicy(permissionMode: PermissionMode, planMode: Bool) -> String { + if planMode { return "on-request" } + switch permissionMode { + case .default, .plan: return "untrusted" + case .acceptEdits, .auto: return "on-request" + case .bypassPermissions: return "never" + } + } + + private static func codexSandboxMode(permissionMode: PermissionMode, planMode: Bool) -> String { + if planMode { return "read-only" } + switch permissionMode { + case .bypassPermissions: return "danger-full-access" + default: return "workspace-write" + } + } + + private static func codexSandboxPolicy(permissionMode: PermissionMode, planMode: Bool) -> JSONValue { + if planMode { + return .object(["type": .string("readOnly"), "networkAccess": .bool(false)]) + } + switch permissionMode { + case .bypassPermissions: + return .object(["type": .string("dangerFullAccess")]) + default: + return .object(["type": .string("workspaceWrite")]) + } + } + private func spawnAppServer(binary: String, streamId: UUID, cwd: String?) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { let process = Process() process.executableURL = URL(fileURLWithPath: binary) diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index c1ceeae4..a6797df2 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -639,7 +639,9 @@ struct ChatDetailModifiers: ViewModifier { private var presentedRequest: PermissionRequest? { guard let id = windowState.presentedPermissionId else { return nil } - return windowState.pendingPermissions.first(where: { $0.id == id }) + return windowState.pendingPermissions.first { + $0.id == id && $0.sessionId == windowState.currentSessionId + } } private var presentedPermissionModalRequest: PermissionRequest? { diff --git a/RxCode/Views/Permission/PermissionQueueBanner.swift b/RxCode/Views/Permission/PermissionQueueBanner.swift index 6bcf2477..94dad0da 100644 --- a/RxCode/Views/Permission/PermissionQueueBanner.swift +++ b/RxCode/Views/Permission/PermissionQueueBanner.swift @@ -10,7 +10,7 @@ struct PermissionQueueBanner: View { @State private var isHovered: Bool = false private var pendingRequests: [PermissionRequest] { - windowState.pendingPermissions + windowState.pendingPermissions.filter { $0.sessionId == windowState.currentSessionId } } private var questionCount: Int { diff --git a/RxCode/Views/SettingsView.swift b/RxCode/Views/SettingsView.swift index 27c9e066..263835e4 100644 --- a/RxCode/Views/SettingsView.swift +++ b/RxCode/Views/SettingsView.swift @@ -621,7 +621,7 @@ struct ChatSettingsTab: View { Text("Summarization Model") .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - Text("Used to generate short session titles. The default follows the selected chat client.") + Text("Used to generate short session titles. The default follows each thread's model.") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) @@ -639,15 +639,7 @@ struct ChatSettingsTab: View { switch appState.summarizationProvider { case .selectedClient: - Text("Uses \(appState.selectedAgentProvider.displayName) with \(selectedDefaultModel.displayName).") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - case .claudeCode: - Text("Uses Claude Code with \(summarizationModelName(for: .claudeCode)).") - .font(.system(size: ClaudeTheme.size(11))) - .foregroundStyle(.secondary) - case .codex: - Text("Uses Codex with \(summarizationModelName(for: .codex)).") + Text("Uses the model saved on the current thread.") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) case .openAI: @@ -729,17 +721,6 @@ struct ChatSettingsTab: View { } } - private func summarizationModelName(for provider: AgentProvider) -> String { - if appState.selectedAgentProvider == provider { - return selectedDefaultModel.displayName - } - let model = appState.availableAgentModelSections() - .first(where: { $0.provider == provider })? - .models - .first - return model?.displayName ?? "the first available model" - } - // MARK: - Permission Mode Section private var permissionModeSection: some View { From f5bf12ecb5ca64d808af01119b7e5b797033d4b4 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Thu, 14 May 2026 20:08:46 +0800 Subject: [PATCH 2/2] fix: mcp --- AGENTS.md | 8 +- CLAUDE.md | 28 +- .../Sources/RxCodeCore/Models/MCPServer.swift | 105 ++- .../RxCodeCore/Models/StreamEvent.swift | 13 + .../Sources/RxCodeCore/Models/TodoItem.swift | 27 + .../RxCodeCore/Models/TodoSnapshot.swift | 4 +- .../RxCodeCoreTests/TodoExtractorTests.swift | 44 ++ RxCode/App/AppState.swift | 57 +- RxCode/Services/ClaudeService.swift | 196 ++++- RxCode/Services/CodexAppServer.swift | 78 +- RxCode/Services/MCPService.swift | 667 ++++++++++-------- RxCode/Views/Settings/MCPSettingsTab.swift | 197 ++++-- .../Toolbar/TodoProgressToolbarItem.swift | 10 +- 13 files changed, 1026 insertions(+), 408 deletions(-) create mode 100644 Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift diff --git a/AGENTS.md b/AGENTS.md index c14caabb..9daa9861 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ RxCode is a native macOS desktop client for the Codex CLI. Written in Swift + Sw ## Writing Rules -- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. Chat responses to the user remain in Korean. +- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. ## Build & Run @@ -41,8 +41,8 @@ xcodebuild -project RxCode.xcodeproj -scheme RxCode -configuration Release build The codebase is split into two Swift packages under `Packages/`: -| Package | Role | -| -------------- | ---------------------------------------------------------------- | +| Package | Role | +| --------------- | ---------------------------------------------------------------- | | `RxCodeCore` | Shared models, theme, utilities — no UI dependencies | | `RxCodeChatKit` | Chat UI components (ChatView, MessageBubble, InputBarView, etc.) | @@ -53,7 +53,7 @@ The codebase is split into two Swift packages under `Packages/`: | `ClaudeService` | Spawns Codex CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals | | `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval | | `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning | -| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure | +| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure | | `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache | | `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking | | `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks | diff --git a/CLAUDE.md b/CLAUDE.md index 5830ca99..a15cfee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ RxCode is a native macOS desktop client for the Claude Code CLI. Written in Swif ## Writing Rules -- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. Chat responses to the user remain in Korean. +- All text committed to the project — code comments, commit messages, PR descriptions, log messages — must be written in **English**. ## Build & Run @@ -41,23 +41,23 @@ xcodebuild -project RxCode.xcodeproj -scheme RxCode -configuration Release build The codebase is split into two Swift packages under `Packages/`: -| Package | Role | -|---------|------| -| `RxCodeCore` | Shared models, theme, utilities — no UI dependencies | +| Package | Role | +| --------------- | ---------------------------------------------------------------- | +| `RxCodeCore` | Shared models, theme, utilities — no UI dependencies | | `RxCodeChatKit` | Chat UI components (ChatView, MessageBubble, InputBarView, etc.) | ### Service Layer (`Services/`) -| Service | Role | -|---------|------| -| `ClaudeService` | Spawns Claude CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals | -| `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval | -| `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning | -| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure | -| `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache | -| `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking | -| `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks | -| `BashSafety` | Whitelist-based read-only command validator. Blocks mutating git/claude/npm subcommands and write redirections | +| Service | Role | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `ClaudeService` | Spawns Claude CLI as a subprocess, parses stdout NDJSON stream, buffers text deltas at 50ms intervals | +| `PermissionServer` | Network framework-based local HTTP server (ports 19836–19846). Receives CLI PreToolUse hook requests and holds the connection until UI approval | +| `GitHubService` | OAuth Device Flow authentication, Keychain token storage, SSH key generation/registration, repo cloning | +| `PersistenceService` | JSON file-based persistence at `~/Library/Application Support/RxCode/`. Per-project/session directory structure | +| `MarketplaceService` | Parallel fetch of plugin catalog from 4 Anthropic GitHub repos, 5-minute cache | +| `RateLimitService` | Anthropic usage API polling, OAuth token refresh, usage tracking | +| `UpdateService` | Sparkle-based auto-update manager. Starts updater on launch; exposes `checkForUpdates()` for menu-initiated checks | +| `BashSafety` | Whitelist-based read-only command validator. Blocks mutating git/claude/npm subcommands and write redirections | ### Data Flow diff --git a/Packages/Sources/RxCodeCore/Models/MCPServer.swift b/Packages/Sources/RxCodeCore/Models/MCPServer.swift index 2557bef0..86eabd27 100644 --- a/Packages/Sources/RxCodeCore/Models/MCPServer.swift +++ b/Packages/Sources/RxCodeCore/Models/MCPServer.swift @@ -42,6 +42,29 @@ public enum MCPScope: String, Codable, Sendable, CaseIterable, Identifiable { } } +public enum MCPProjectOverride: String, Codable, Sendable, CaseIterable, Identifiable { + case inherit + case enabled + case disabled + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .inherit: return "Inherit" + case .enabled: return "On" + case .disabled: return "Off" + } + } +} + +public enum MCPProvider: String, Codable, Sendable, CaseIterable, Identifiable { + case claudeCode + case codex + + public var id: String { rawValue } +} + // MARK: - Status public enum MCPStatus: Sendable, Equatable { @@ -75,14 +98,30 @@ public struct MCPServerInfo: Identifiable, Sendable, Equatable, Hashable { public let status: MCPStatus public var scope: MCPScope? public var projectPath: String? + public var isGloballyEnabled: Bool + public var projectOverride: MCPProjectOverride + public var effectiveEnabled: Bool - public init(name: String, transport: MCPTransport, endpoint: String, status: MCPStatus, scope: MCPScope? = nil, projectPath: String? = nil) { + public init( + name: String, + transport: MCPTransport, + endpoint: String, + status: MCPStatus, + scope: MCPScope? = nil, + projectPath: String? = nil, + isGloballyEnabled: Bool = true, + projectOverride: MCPProjectOverride = .inherit, + effectiveEnabled: Bool = true + ) { self.name = name self.transport = transport self.endpoint = endpoint self.status = status self.scope = scope self.projectPath = projectPath + self.isGloballyEnabled = isGloballyEnabled + self.projectOverride = projectOverride + self.effectiveEnabled = effectiveEnabled } public func hash(into hasher: inout Hasher) { @@ -144,6 +183,70 @@ public struct MCPServerSpec: Sendable, Equatable { } } +public struct MCPServerRecord: Codable, Sendable, Equatable, Identifiable { + public var id: String { name } + public var name: String + public var transport: MCPTransport + public var url: String? + public var command: String? + public var args: [String] + public var env: [String: String] + public var headers: [String: String] + public var cwd: String? + public var bearerTokenEnvVar: String? + public var isGloballyEnabled: Bool + public var projectOverrides: [String: MCPProjectOverride] + + public init( + name: String, + transport: MCPTransport, + url: String? = nil, + command: String? = nil, + args: [String] = [], + env: [String: String] = [:], + headers: [String: String] = [:], + cwd: String? = nil, + bearerTokenEnvVar: String? = nil, + isGloballyEnabled: Bool = true, + projectOverrides: [String: MCPProjectOverride] = [:] + ) { + self.name = name + self.transport = transport + self.url = url + self.command = command + self.args = args + self.env = env + self.headers = headers + self.cwd = cwd + self.bearerTokenEnvVar = bearerTokenEnvVar + self.isGloballyEnabled = isGloballyEnabled + self.projectOverrides = projectOverrides + } + + public func projectOverride(for projectPath: String?) -> MCPProjectOverride { + guard let projectPath, !projectPath.isEmpty else { return .inherit } + return projectOverrides[projectPath] ?? .inherit + } + + public func isEnabled(for projectPath: String?) -> Bool { + switch projectOverride(for: projectPath) { + case .inherit: return isGloballyEnabled + case .enabled: return true + case .disabled: return false + } + } +} + +public struct MCPConfiguration: Codable, Sendable, Equatable { + public var version: Int + public var servers: [MCPServerRecord] + + public init(version: Int = 1, servers: [MCPServerRecord] = []) { + self.version = version + self.servers = servers + } +} + public struct MCPKeyValue: Sendable, Equatable, Identifiable { public let id: UUID public var key: String diff --git a/Packages/Sources/RxCodeCore/Models/StreamEvent.swift b/Packages/Sources/RxCodeCore/Models/StreamEvent.swift index 5e1b99b9..558b56a9 100644 --- a/Packages/Sources/RxCodeCore/Models/StreamEvent.swift +++ b/Packages/Sources/RxCodeCore/Models/StreamEvent.swift @@ -8,6 +8,7 @@ public enum StreamEvent: Sendable { case user(UserMessage) case result(ResultEvent) case rateLimitEvent(RateLimitInfo) + case todoSnapshot(TodoSnapshotEvent) case unknown(String) } @@ -67,6 +68,18 @@ public struct UserMessage: Sendable { } } +// MARK: - Todo Snapshot Event + +public struct TodoSnapshotEvent: Sendable { + public let sessionId: String? + public let items: [TodoItem] + + public init(sessionId: String?, items: [TodoItem]) { + self.sessionId = sessionId + self.items = items + } +} + // MARK: - Usage Info public struct UsageInfo: Sendable { diff --git a/Packages/Sources/RxCodeCore/Models/TodoItem.swift b/Packages/Sources/RxCodeCore/Models/TodoItem.swift index a503efdd..0d23ca59 100644 --- a/Packages/Sources/RxCodeCore/Models/TodoItem.swift +++ b/Packages/Sources/RxCodeCore/Models/TodoItem.swift @@ -53,4 +53,31 @@ public enum TodoExtractor { return TodoItem(id: index, content: content, activeForm: activeForm, status: status) } } + + /// Parse Codex app-server `turn/plan/updated` params into the same todo + /// shape used by `TodoWrite`. + public static func parseCodexPlanUpdate(params: [String: JSONValue]) -> [TodoItem]? { + guard let array = params["plan"]?.arrayValue else { return nil } + return array.enumerated().map { index, value in + let step = value["step"]?.stringValue ?? "" + let rawStatus = value["status"]?.stringValue ?? "" + return TodoItem( + id: index, + content: step, + activeForm: step, + status: parseCodexPlanStatus(rawStatus) + ) + } + } + + private static func parseCodexPlanStatus(_ rawStatus: String) -> TodoItem.Status { + switch rawStatus { + case "completed": + return .completed + case "inProgress", "in_progress": + return .inProgress + default: + return .pending + } + } } diff --git a/Packages/Sources/RxCodeCore/Models/TodoSnapshot.swift b/Packages/Sources/RxCodeCore/Models/TodoSnapshot.swift index 4f109ade..5294b1a9 100644 --- a/Packages/Sources/RxCodeCore/Models/TodoSnapshot.swift +++ b/Packages/Sources/RxCodeCore/Models/TodoSnapshot.swift @@ -1,8 +1,8 @@ import Foundation import SwiftData -/// Persisted snapshot of the latest `TodoWrite` tool call for a chat session. -/// One row per session id; updated each time the CLI emits a new TodoWrite. +/// Persisted snapshot of the latest todo-style progress for a chat session. +/// One row per session id; updated from Claude `TodoWrite` or Codex plan events. @Model public final class TodoSnapshot { @Attribute(.unique) public var sessionId: String diff --git a/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift b/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift new file mode 100644 index 00000000..236f6631 --- /dev/null +++ b/Packages/Tests/RxCodeCoreTests/TodoExtractorTests.swift @@ -0,0 +1,44 @@ +import Testing +@testable import RxCodeCore + +@Suite("Todo extraction") +struct TodoExtractorTests { + + @Test("Codex plan update maps steps to todos") + func codexPlanUpdateMapsStepsToTodos() { + let todos = TodoExtractor.parseCodexPlanUpdate(params: [ + "threadId": .string("thread-1"), + "turnId": .string("turn-1"), + "plan": .array([ + .object([ + "step": .string("Inspect app-server schema"), + "status": .string("completed") + ]), + .object([ + "step": .string("Wire plan updates into todo UI"), + "status": .string("inProgress") + ]), + .object([ + "step": .string("Run focused tests"), + "status": .string("pending") + ]) + ]) + ]) + + #expect(todos == [ + TodoItem(id: 0, content: "Inspect app-server schema", activeForm: "Inspect app-server schema", status: .completed), + TodoItem(id: 1, content: "Wire plan updates into todo UI", activeForm: "Wire plan updates into todo UI", status: .inProgress), + TodoItem(id: 2, content: "Run focused tests", activeForm: "Run focused tests", status: .pending) + ]) + } + + @Test("Missing Codex plan returns nil") + func missingCodexPlanReturnsNil() { + let todos = TodoExtractor.parseCodexPlanUpdate(params: [ + "threadId": .string("thread-1"), + "turnId": .string("turn-1") + ]) + + #expect(todos == nil) + } +} diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index dd6d7ce7..2d5bc1fa 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -733,9 +733,9 @@ final class AppState { mcpListError = nil defer { mcpIsLoading = false } do { - // Pass nil so Settings aggregates across every project in ~/.claude.json, - // not just the currently-selected project. - let list = try await mcp.list(projectPath: nil) + // Pass the active project so Settings can show global defaults plus + // the effective per-project override state. + let list = try await mcp.list(projectPath: activeProjectPath) // Preserve last-known status for rows that already exist so a list // refresh doesn't visually downgrade everything to .unknown. var merged: [MCPServerInfo] = [] @@ -748,7 +748,10 @@ final class AppState { endpoint: info.endpoint, status: existing.status, scope: info.scope, - projectPath: info.projectPath + projectPath: info.projectPath, + isGloballyEnabled: info.isGloballyEnabled, + projectOverride: info.projectOverride, + effectiveEnabled: info.effectiveEnabled )) } else { merged.append(info) @@ -798,7 +801,10 @@ final class AppState { endpoint: row.endpoint, status: newStatus, scope: row.scope, - projectPath: row.projectPath + projectPath: row.projectPath, + isGloballyEnabled: row.isGloballyEnabled, + projectOverride: row.projectOverride, + effectiveEnabled: row.effectiveEnabled ) } @@ -817,7 +823,7 @@ final class AppState { @discardableResult func addMCPServer(spec: MCPServerSpec, scope: MCPScope) async -> String? { do { - try await mcp.add(spec: spec, scope: scope) + try await mcp.add(spec: spec, scope: scope, projectPath: activeProjectPath) await refreshMCPServers() // Auto-probe on add so the new row shows live status and tool list // without the user clicking Test. @@ -846,6 +852,31 @@ final class AppState { } } + @discardableResult + func setMCPServerGlobalEnabled(name: String, enabled: Bool) async -> String? { + do { + try await mcp.setGlobalEnabled(name: name, enabled: enabled) + await refreshMCPServers() + return nil + } catch { + return error.localizedDescription + } + } + + @discardableResult + func setMCPServerProjectOverride(name: String, override: MCPProjectOverride) async -> String? { + guard let activeProjectPath else { + return "No active project selected." + } + do { + try await mcp.setProjectOverride(name: name, projectPath: activeProjectPath, override: override) + await refreshMCPServers() + return nil + } catch { + return error.localizedDescription + } + } + /// Spawn the periodic MCP probe loop. Idempotent. func startMCPPeriodicProbe() { guard mcpPeriodicProbeTask == nil else { return } @@ -1759,6 +1790,7 @@ final class AppState { let stream: AsyncStream switch agentProvider { case .claudeCode: + let mcpConfigPath = await mcp.writeClaudeConfig(projectPath: cwd) stream = await claude.send( streamId: streamId, prompt: prompt, @@ -1767,9 +1799,11 @@ final class AppState { model: model, effort: effort, hookSettingsPath: hookSettingsPath, + mcpConfigPath: mcpConfigPath, permissionMode: permissionMode ) case .codex: + let mcpConfigOverrides = await mcp.codexConfigOverrides(projectPath: cwd) stream = await codex.send( streamId: streamId, prompt: prompt, @@ -1778,6 +1812,7 @@ final class AppState { model: model, permissionMode: registerMode, planMode: permissionMode == .plan, + mcpConfigOverrides: mcpConfigOverrides, permissionServer: permission ) } @@ -2108,6 +2143,15 @@ final class AppState { addErrorMessage("Rate limited. Retrying in \(Int(retry))s...", in: window) } + case .todoSnapshot(let snapshot): + let targetSession = snapshot.sessionId ?? sessionKey + let done = snapshot.items.filter { $0.status == .completed }.count + let active = snapshot.items.first(where: { $0.status == .inProgress })?.activeForm ?? "-" + logger.info( + "[TodoSnapshot] session=\(targetSession, privacy: .public) total=\(snapshot.items.count) done=\(done) active=\(active, privacy: .public)" + ) + threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) + case .unknown(let raw): if eventCount <= 5 || eventCount % 100 == 0 { logger.debug("[Stream:UI] event #\(eventCount) .unknown (gap=\(String(format: "%.1f", gap))s, len=\(raw.count))") @@ -2746,6 +2790,7 @@ final class AppState { } activeProjectPath = project.path + Task { await refreshMCPServers() } UserDefaults.standard.set(project.id.uuidString, forKey: "selectedProjectId") } diff --git a/RxCode/Services/ClaudeService.swift b/RxCode/Services/ClaudeService.swift index f5386509..85dcc086 100644 --- a/RxCode/Services/ClaudeService.swift +++ b/RxCode/Services/ClaudeService.swift @@ -14,12 +14,22 @@ actor ClaudeCodeServer { /// PGIDs of concurrently running streaming CLI invocations — managed independently per streamId. /// - /// The streaming `claude` is launched in its own process group (via `posix_spawn` + - /// `POSIX_SPAWN_SETPGROUP`) so the leader pid == pgid. This lets us reap the + /// The streaming `claude` is launched as a new session leader (via `posix_spawn` + + /// `POSIX_SPAWN_SETSID`) so the leader pid == pgid == sid. This lets us reap the /// entire subagent subtree with a single `killpg` instead of chasing descendants - /// individually — node subagents spawned via the Task tool inherit the same group - /// and get swept along. + /// individually, and also enables session-id filtering to find descendants whose + /// parent chain was severed by reparenting to launchd. private var streamPGIDs: [UUID: pid_t] = [:] + /// Accumulated set of every descendant pid ever observed for a stream. A background + /// poller samples the live process table while the stream is running and unions the + /// results here. This is the only way to catch descendants that call `setsid()` + /// themselves (creating a new session that doesn't match our root sid) and then + /// get reparented to launchd when an intermediate parent dies — by the time + /// `finalize` runs, those processes are invisible to both the ppid walk and the + /// session-id filter, but they were briefly findable while their parent was alive. + private var trackedDescendants: [UUID: Set] = [:] + /// Polling tasks that populate `trackedDescendants`. Cancelled in `removeProcess`. + private var descendantTrackers: [UUID: Task] = [:] /// Writable stdin handles per stream — used for sending follow-up messages (e.g., AskUserQuestion responses). /// Entry is removed when stdin is closed (after `result` event or on cancel). private var stdinHandles: [UUID: FileHandle] = [:] @@ -334,6 +344,7 @@ actor ClaudeCodeServer { model: String? = nil, effort: String? = nil, hookSettingsPath: String? = nil, + mcpConfigPath: String? = nil, permissionMode: PermissionMode = .default ) -> AsyncStream { let stdin = Pipe() @@ -362,6 +373,7 @@ actor ClaudeCodeServer { model: model, effort: effort, hookSettingsPath: hookSettingsPath, + mcpConfigPath: mcpConfigPath, permissionMode: permissionMode, stdinPipe: stdin, stdoutPipe: stdout, @@ -443,26 +455,70 @@ actor ClaudeCodeServer { } } + // MARK: - Descendant tracker + + /// Start a background poller that periodically snapshots every descendant of `root` + /// and merges them into `trackedDescendants[streamId]`. The accumulated set is the + /// safety net for descendants that briefly exist as findable children of `root` + /// before detaching themselves (via `setsid`/`setpgid`) and being reparented away. + /// + /// Cancelled in `removeProcess` when the stream ends. + private func startDescendantTracker(streamId: UUID, root: pid_t) { + // Capture sid once at startup — getsid() on a live root returns the session id, + // which equals `root` itself since we spawned with POSIX_SPAWN_SETSID. + let sid = getsid(root) + let task = Task.detached { [weak self] in + while !Task.isCancelled { + let pids = Self.descendantPids(of: root, sid: sid) + if !pids.isEmpty { + await self?.mergeTrackedDescendants(streamId: streamId, pids: pids) + } + // 500ms is a balance: short enough to catch transient ppid links before + // an intermediate parent dies and reparenting hides the child, while not + // burning measurable CPU on the `ps` invocation (~5ms per snapshot). + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + descendantTrackers[streamId] = task + } + + private func mergeTrackedDescendants(streamId: UUID, pids: [pid_t]) { + trackedDescendants[streamId, default: []].formUnion(pids) + } + + /// Union of the live snapshot and every descendant ever seen for this stream. + /// `kill(pid, 0)` filters out already-reaped pids so signals only target live ones. + private func allKnownDescendants(streamId: UUID, root: pid_t, sid: pid_t) -> [pid_t] { + var union = trackedDescendants[streamId] ?? [] + union.formUnion(Self.descendantPids(of: root, sid: sid)) + return union.filter { kill($0, 0) == 0 } + } + // MARK: - Cancel / Finalize /// User-initiated stop. Send SIGINT to the entire process group so subagent /// children die alongside the parent. Escalate to SIGKILL after 5 seconds. func cancel(streamId: UUID) { guard let pgid = streamPGIDs[streamId] else { return } - let escapees = Self.descendantPids(of: pgid) + // Capture sid while the root is still alive — getsid(pid) returns -1 once + // the process is fully reaped, but the value is needed for the SIGKILL re-snapshot. + let sid = getsid(pgid) + let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) logger.info("Sending SIGINT to claude pgid \(pgid) escapees=\(escapees) (stream=\(streamId))") killpg(pgid, SIGINT) for pid in escapees { kill(pid, SIGINT) } let log = logger - Task.detached { + Task.detached { [weak self] in try? await Task.sleep(nanoseconds: 5_000_000_000) + // Re-snapshot before SIGKILL — picks up anything that emerged in the 5s window. + let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] // killpg/kill on a fully-dead target returns ESRCH — harmless. Send unconditionally // to cover any subagent that ignored SIGINT or escaped the process group. killpg(pgid, SIGKILL) - for pid in escapees { kill(pid, SIGKILL) } - log.debug("Cancel SIGKILL pgid=\(pgid) escapees=\(escapees)") + for pid in finalEscapees { kill(pid, SIGKILL) } + log.debug("Cancel SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") } } @@ -475,30 +531,48 @@ actor ClaudeCodeServer { /// escapee individually as the safety net — running in a detached Task so /// actor contention can't delay the escalation. func finalize(streamId: UUID) { - guard let pgid = streamPGIDs[streamId] else { return } - let escapees = Self.descendantPids(of: pgid) - - logger.info("Finalizing stream — pgid=\(pgid) escapees=\(escapees) stream=\(streamId)") + // Claim the entry atomically so a second concurrent caller (e.g. AppState's + // result-driven finalize racing with the waitpid-driven handleProcessExit) + // becomes a no-op instead of re-signaling an already-reaped pgid. + guard let pgid = streamPGIDs.removeValue(forKey: streamId) else { return } + // Capture sid while the root is still alive — see cancel() for rationale. + let sid = getsid(pgid) + let escapees = allKnownDescendants(streamId: streamId, root: pgid, sid: sid) + + logger.info("Finalizing stream — pgid=\(pgid) sid=\(sid) escapees=\(escapees) stream=\(streamId)") killpg(pgid, SIGTERM) for pid in escapees { kill(pid, SIGTERM) } let log = logger - Task.detached { + Task.detached { [weak self] in try? await Task.sleep(nanoseconds: 1_500_000_000) + // Re-snapshot before SIGKILL. By this time the root may have already + // exited; the accumulated `trackedDescendants` set is what catches + // session-escaped, reparented processes that no live ps query can find. + let finalEscapees = await self?.allKnownDescendants(streamId: streamId, root: pgid, sid: sid) ?? [] killpg(pgid, SIGKILL) - for pid in escapees { kill(pid, SIGKILL) } - log.debug("Finalize SIGKILL pgid=\(pgid) escapees=\(escapees)") + for pid in finalEscapees { kill(pid, SIGKILL) } + log.debug("Finalize SIGKILL pgid=\(pgid) escapees=\(finalEscapees)") } } - /// Walk the process tree rooted at `root` via `ps -Ao pid,ppid` and return - /// every descendant pid (not including `root`). Used to find subagents that - /// escaped the original process group so they can be signaled directly. + /// Find every descendant pid of `root` (not including `root`). Combines two + /// strategies for maximum coverage: + /// + /// 1. **Parent walk** — BFS via `ps -Ao pid,ppid`. Catches everything reachable + /// through ppid links from `root`. Works for live, non-reparented trees but + /// breaks once an intermediate parent dies and its children are reparented + /// to launchd (ppid=1). + /// 2. **Session match** — for each running pid, compare `getsid(pid)` to the + /// passed-in `sid`. Catches descendants that broke out of the pgid (called + /// `setpgid`) and whose ppid chain was severed by reparenting, as long as + /// they did not call `setsid` themselves. Relies on the root having been + /// spawned with `POSIX_SPAWN_SETSID` so that `sid == root`. /// - /// Must be called *before* the root dies — once the root exits, its children - /// are reparented to launchd (ppid=1) and the link back to the original - /// process is lost. - private static func descendantPids(of root: pid_t) -> [pid_t] { + /// Pass `sid: 0` to skip the session match (e.g., if `getsid(root)` already + /// returned an error). Callers should capture `sid` while the root is alive, + /// since `getsid()` on a reaped pid returns -1. + private static func descendantPids(of root: pid_t, sid: pid_t) -> [pid_t] { let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/ps") proc.arguments = ["-Ao", "pid,ppid"] @@ -515,23 +589,41 @@ actor ClaudeCodeServer { guard let text = String(data: data, encoding: .utf8) else { return [] } var childrenByParent: [pid_t: [pid_t]] = [:] + var allPids: [pid_t] = [] for line in text.split(separator: "\n").dropFirst() { let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) guard parts.count >= 2, let pid = pid_t(parts[0]), let ppid = pid_t(parts[1]) else { continue } childrenByParent[ppid, default: []].append(pid) + allPids.append(pid) } - var result: [pid_t] = [] + var result = Set() + + // (1) BFS via ppid links — works while parent chain is intact. var queue: [pid_t] = [root] while !queue.isEmpty { let next = queue.removeFirst() guard let children = childrenByParent[next] else { continue } - result.append(contentsOf: children) - queue.append(contentsOf: children) + for child in children where child != root { + if result.insert(child).inserted { + queue.append(child) + } + } + } + + // (2) Session-id match — survives reparenting; misses processes that + // called setsid themselves (rare for CLI subagents). + if sid > 0 { + for pid in allPids where pid != root { + if getsid(pid) == sid { + result.insert(pid) + } + } } - return result + + return Array(result) } // MARK: - Private Helpers @@ -546,6 +638,7 @@ actor ClaudeCodeServer { model: String?, effort: String?, hookSettingsPath: String?, + mcpConfigPath: String?, permissionMode: PermissionMode ) -> [String] { var args: [String] = [ @@ -578,6 +671,10 @@ actor ClaudeCodeServer { args += ["--settings", hookSettingsPath] } + if let mcpConfigPath { + args += ["--strict-mcp-config", "--mcp-config", mcpConfigPath] + } + if let sessionId { args += ["--resume", sessionId] } @@ -609,6 +706,7 @@ actor ClaudeCodeServer { model: String?, effort: String? = nil, hookSettingsPath: String?, + mcpConfigPath: String?, permissionMode: PermissionMode = .default, stdinPipe: Pipe, stdoutPipe: Pipe, @@ -624,6 +722,7 @@ actor ClaudeCodeServer { model: model, effort: effort, hookSettingsPath: hookSettingsPath, + mcpConfigPath: mcpConfigPath, permissionMode: permissionMode ) let environment = await resolvedEnvironment() @@ -658,6 +757,7 @@ actor ClaudeCodeServer { let stdinHandle = stdinPipe.fileHandleForWriting self.streamPGIDs[streamId] = pid self.stdinHandles[streamId] = stdinHandle + self.startDescendantTracker(streamId: streamId, root: pid) // Send the initial user prompt as an NDJSON user message. let userMessage: [String: Any] = [ @@ -740,8 +840,11 @@ actor ClaudeCodeServer { } defer { posix_spawnattr_destroy(&attr) } - _ = posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP)) - _ = posix_spawnattr_setpgroup(&attr, 0) + // SETSID makes the child a new session leader (sid == pid == pgid). Session id + // is preserved across reparenting, so we can locate descendants via getsid() + // even after an intermediate parent has died and orphans were reparented to + // launchd. Pure SETPGROUP would not survive reparenting on its own. + _ = posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETSID)) var argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } argv.append(nil) @@ -789,12 +892,25 @@ actor ClaudeCodeServer { /// the waitpid-driven exit handler. private func removeProcess(streamId: UUID) { streamPGIDs.removeValue(forKey: streamId) + descendantTrackers.removeValue(forKey: streamId)?.cancel() + // Retain `trackedDescendants[streamId]` long enough for the SIGKILL re-snapshot + // in cancel/finalize (those run in detached tasks after this method). Clear it + // after a short delay so the actor doesn't accumulate stale entries. + let clearKey = streamId + Task { [weak self] in + try? await Task.sleep(nanoseconds: 6_000_000_000) + await self?.clearTrackedDescendants(streamId: clearKey) + } // If stdin is still open (e.g. abnormal exit before `result`), release the handle. if let handle = stdinHandles.removeValue(forKey: streamId) { try? handle.close() } } + private func clearTrackedDescendants(streamId: UUID) { + trackedDescendants.removeValue(forKey: streamId) + } + private func recordSessionId(streamId: UUID, sessionId: String) { streamSessionIds[streamId] = sessionId } @@ -881,25 +997,37 @@ actor ClaudeCodeServer { inactivityTimer?.cancel() inactivityTimer = nil + // Cancel pollers so they don't race with the teardown. + for (_, task) in descendantTrackers { task.cancel() } + descendantTrackers.removeAll() + // Snapshot every descendant tree before signaling so escaped subagents // (MCP servers detached via setsid) still get the SIGKILL pass. + // Capture sid per stream while roots are alive — see finalize() for rationale. + let streamSnapshots: [(UUID, pid_t, pid_t)] = streamPGIDs.map { ($0.key, $0.value, getsid($0.value)) } var allEscapees: [pid_t] = [] - for (_, pgid) in streamPGIDs { - allEscapees.append(contentsOf: Self.descendantPids(of: pgid)) + for (streamId, pgid, sid) in streamSnapshots { + allEscapees.append(contentsOf: allKnownDescendants(streamId: streamId, root: pgid, sid: sid)) } - for (_, pgid) in streamPGIDs { + for (_, pgid, _) in streamSnapshots { killpg(pgid, SIGTERM) } for pid in allEscapees { kill(pid, SIGTERM) } // Synchronous SIGKILL escalation — the host process is exiting, so a - // detached Task wouldn't have time to fire. + // detached Task wouldn't have time to fire. Re-snapshot to catch any + // descendants that emerged or were reparented between SIGTERM and now. usleep(200_000) - for (_, pgid) in streamPGIDs { + var finalEscapees: [pid_t] = [] + for (streamId, pgid, sid) in streamSnapshots { + finalEscapees.append(contentsOf: allKnownDescendants(streamId: streamId, root: pgid, sid: sid)) + } + for (_, pgid, _) in streamSnapshots { killpg(pgid, SIGKILL) } - for pid in allEscapees { kill(pid, SIGKILL) } + for pid in finalEscapees { kill(pid, SIGKILL) } + trackedDescendants.removeAll() streamPGIDs.removeAll() for (_, handle) in stdinHandles { diff --git a/RxCode/Services/CodexAppServer.swift b/RxCode/Services/CodexAppServer.swift index 6089cf38..6cfc41b8 100644 --- a/RxCode/Services/CodexAppServer.swift +++ b/RxCode/Services/CodexAppServer.swift @@ -276,6 +276,7 @@ actor CodexAppServer { model: String?, permissionMode: PermissionMode, planMode: Bool, + mcpConfigOverrides: [String] = [], permissionServer: PermissionServer ) -> AsyncStream { AsyncStream { continuation in @@ -292,6 +293,7 @@ actor CodexAppServer { model: model, permissionMode: permissionMode, planMode: planMode, + mcpConfigOverrides: mcpConfigOverrides, permissionServer: permissionServer, continuation: continuation ) @@ -329,12 +331,13 @@ actor CodexAppServer { model: String?, permissionMode: PermissionMode, planMode: Bool, + mcpConfigOverrides: [String], permissionServer: PermissionServer, continuation: AsyncStream.Continuation ) async { do { guard let binary = await findCodexBinary() else { throw CodexError.binaryNotFound } - let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd) + let handles = try await spawnAppServer(binary: binary, streamId: streamId, cwd: cwd, configOverrides: mcpConfigOverrides) try Self.writeJSONLine(Self.request(id: 1, method: "initialize", params: initializeParams()), to: handles.stdin) var activeThreadId = threadId @@ -342,6 +345,12 @@ actor CodexAppServer { var turnCompleted = false var finalUsage: UsageInfo? let startedAt = Date() + // Captured per turn so we can synthesize an `ExitPlanMode` tool call when a + // plan-mode turn completes. Codex never emits ExitPlanMode itself — its plan + // arrives as `turn/plan/updated` notifications (steps) and a final agent + // message (concise summary). See PlanCardView for the rendering contract. + var planItems: [TodoItem] = [] + var assistantTextBuffer = "" for try await line in handles.stdout.fileHandleForReading.bytes.lines { guard !Task.isCancelled else { break } @@ -392,10 +401,30 @@ actor CodexAppServer { stdin: handles.stdin ) } else { + let params = object["params"]?.objectValue ?? [:] + switch method { + case "turn/plan/updated": + if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { + planItems = items + } + case "item/agentMessage/delta", "item/agent_message/delta": + if let text = Self.firstString(in: params, keys: ["delta", "text", "content"]) { + assistantTextBuffer += text + } + default: + break + } handleNotification(method: method, object: object, activeThreadId: activeThreadId, continuation: continuation) if method == "turn/completed" || method == "turn/failed" { finalUsage = Self.usageInfo(from: object) ?? finalUsage turnCompleted = method == "turn/completed" + if turnCompleted, planMode { + emitSynthesizedExitPlanMode( + planItems: planItems, + assistantText: assistantTextBuffer, + continuation: continuation + ) + } break } } @@ -479,6 +508,11 @@ actor CodexAppServer { if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { emitToolStart(item: item, continuation: continuation) } + case "turn/plan/updated": + if let items = TodoExtractor.parseCodexPlanUpdate(params: params) { + let sessionId = Self.firstString(in: params, keys: ["threadId", "thread_id"]) ?? activeThreadId + continuation.yield(.todoSnapshot(TodoSnapshotEvent(sessionId: sessionId, items: items))) + } case "item/completed": if let item = params["item"]?.objectValue ?? params["itemInfo"]?.objectValue { emitToolCompletion(item: item, continuation: continuation) @@ -509,6 +543,44 @@ actor CodexAppServer { continuation.yield(.user(UserMessage(toolUseId: id, content: output, isError: isError))) } + /// Synthesize a Claude-shaped `ExitPlanMode` tool call so `PlanCardView` can render + /// an interactive accept/reject card at the end of a Codex plan-mode turn. Plan body + /// is rendered from the latest `update_plan` steps; falls back to the assistant's + /// final summary text if no plan steps were emitted. + private func emitSynthesizedExitPlanMode( + planItems: [TodoItem], + assistantText: String, + continuation: AsyncStream.Continuation + ) { + let stepsMarkdown = Self.planItemsMarkdown(planItems) + let trimmedText = assistantText.trimmingCharacters(in: .whitespacesAndNewlines) + let markdown: String + if !stepsMarkdown.isEmpty { + markdown = stepsMarkdown + } else if !trimmedText.isEmpty { + markdown = trimmedText + } else { + return + } + let id = "codex-plan-\(UUID().uuidString)" + continuation.yield(.unknown(Self.claudeToolStart(id: id, name: "ExitPlanMode"))) + continuation.yield(.unknown(Self.claudeInputDelta(["plan": .string(markdown)]))) + continuation.yield(.unknown(Self.claudeContentBlockStop())) + } + + private static func planItemsMarkdown(_ items: [TodoItem]) -> String { + guard !items.isEmpty else { return "" } + return items.enumerated().map { index, item in + let suffix: String + switch item.status { + case .completed: suffix = " *(completed)*" + case .inProgress: suffix = " *(in progress)*" + case .pending: suffix = "" + } + return "\(index + 1). \(item.content)\(suffix)" + }.joined(separator: "\n") + } + private func handleServerRequest( requestId: String, method: String, @@ -649,10 +721,10 @@ actor CodexAppServer { } } - private func spawnAppServer(binary: String, streamId: UUID, cwd: String?) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { + private func spawnAppServer(binary: String, streamId: UUID, cwd: String?, configOverrides: [String] = []) async throws -> (process: Process, stdin: FileHandle, stdout: Pipe) { let process = Process() process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["app-server", "--listen", "stdio://"] + process.arguments = ["app-server", "--listen", "stdio://"] + configOverrides if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } process.environment = await resolvedEnvironment() diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift index 3a8f86da..a0c2fcb3 100644 --- a/RxCode/Services/MCPService.swift +++ b/RxCode/Services/MCPService.swift @@ -4,11 +4,11 @@ import os // MARK: - MCPService -/// Manages MCP (Model Context Protocol) servers configured for the Claude CLI. +/// Manages RxCode-owned MCP server definitions and per-project activation. /// -/// All persistent state is owned by `claude mcp …` — this service just shells out -/// for add/list/get/remove and runs an in-process JSON-RPC handshake to test -/// connections and enumerate the tools each server exposes. +/// RxCode is the source of truth. Provider-specific config is generated at +/// launch time for Claude Code and Codex instead of treating either CLI's +/// native config as authoritative. actor MCPService { private let claudeService: ClaudeService @@ -21,17 +21,13 @@ actor MCPService { // MARK: - Errors enum MCPError: LocalizedError { - case binaryNotFound - case cliFailed(Int32, String) case parseFailure(String) case probeTimeout case probeFailed(String) var errorDescription: String? { switch self { - case .binaryNotFound: return "Could not find the claude CLI binary." - case .cliFailed(let s, let m): return "claude exited with status \(s): \(m)" - case .parseFailure(let detail): return "Could not parse claude output: \(detail)" + case .parseFailure(let detail): return "Could not parse MCP config: \(detail)" case .probeTimeout: return "MCP server did not respond within the timeout." case .probeFailed(let detail): return detail } @@ -40,137 +36,139 @@ actor MCPService { // MARK: - List / Get - /// Read MCP server configs straight from disk: - /// User scope -> ~/.claude.json :: mcpServers - /// Local scope -> ~/.claude.json :: projects[].mcpServers - /// Project scope -> /.mcp.json :: mcpServers - /// Servers listed in `projects[].disabledMcpjsonServers` are filtered out - /// of the Project section, matching what Claude Code actually loads. - /// Status is always `.unknown` from the file read alone — connection state comes from a probe. - /// - /// When `projectPath` is nil, aggregate Local/Project rows across every project in - /// `~/.claude.json`. This is what the (window-less) Settings sheet uses so the list - /// matches the union of `claude mcp list` run from each project directory. func list(projectPath: String?) async throws -> [MCPServerInfo] { - let root = readClaudeRoot() - var rows: [MCPServerInfo] = [] + let config = try loadConfig() + return config.servers + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { makeInfo(record: $0, projectPath: projectPath) } + } - for (name, entry) in (root?.mcpServers ?? [:]).sorted(by: { $0.key < $1.key }) { - rows.append(makeInfo(name: name, entry: entry, scope: .user, projectPath: nil)) + func get(name: String, projectPath: String?) async throws -> MCPServerDetail { + guard let record = try loadConfig().servers.first(where: { $0.name == name }) else { + throw MCPError.parseFailure("MCP server '\(name)' not found") } + return makeDetail(record: record, projectPath: projectPath) + } - let projectPaths: [String] - if let projectPath, !projectPath.isEmpty { - projectPaths = [projectPath] - } else { - projectPaths = (root?.projects?.keys.sorted() ?? []) - } + func get(name: String, scope: MCPScope?, projectPath: String?) async throws -> MCPServerDetail { + try await get(name: name, projectPath: projectPath) + } - for path in projectPaths { - let projectEntry = root?.projects?[path] - for (name, entry) in (projectEntry?.mcpServers ?? [:]).sorted(by: { $0.key < $1.key }) { - rows.append(makeInfo(name: name, entry: entry, scope: .local, projectPath: path)) - } + // MARK: - Mutations - let disabled = Set(projectEntry?.disabledMcpjsonServers ?? []) - let projectFile = readProjectMCPFile(projectRoot: path) - for (name, entry) in (projectFile?.mcpServers ?? [:]).sorted(by: { $0.key < $1.key }) { - guard !disabled.contains(name) else { continue } - rows.append(makeInfo(name: name, entry: entry, scope: .project, projectPath: path)) - } - } + func add(spec: MCPServerSpec, scope: MCPScope, projectPath: String?) async throws { + var config = try loadConfig() + let name = spec.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw MCPError.parseFailure("Server name is required.") } - return rows - } + let env = Dictionary(uniqueKeysWithValues: spec.env.compactMap { kv -> (String, String)? in + let key = kv.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : (key, kv.value) + }) + let headers = Dictionary(uniqueKeysWithValues: spec.headers.compactMap { kv -> (String, String)? in + let key = kv.key.trimmingCharacters(in: .whitespacesAndNewlines) + return key.isEmpty ? nil : (key, kv.value) + }) - /// Resolve one server by name. Precedence matches the CLI: Local > Project > User. - /// When `projectPath` is nil, scan every project in `~/.claude.json` for a match. - func get(name: String, projectPath: String?) async throws -> MCPServerDetail { - let root = readClaudeRoot() + var record = MCPServerRecord( + name: name, + transport: spec.transport, + url: spec.transport == .stdio ? nil : spec.url.trimmingCharacters(in: .whitespacesAndNewlines), + command: spec.transport == .stdio ? spec.command.trimmingCharacters(in: .whitespacesAndNewlines) : nil, + args: spec.args, + env: env, + headers: headers, + isGloballyEnabled: true + ) - let candidatePaths: [String] - if let projectPath, !projectPath.isEmpty { - candidatePaths = [projectPath] - } else { - candidatePaths = (root?.projects?.keys.sorted() ?? []) + if scope != .user, let projectPath, !projectPath.isEmpty { + record.isGloballyEnabled = false + record.projectOverrides[projectPath] = .enabled } - for path in candidatePaths { - if let entry = root?.projects?[path]?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .local, projectPath: path) - } - let disabled = Set(root?.projects?[path]?.disabledMcpjsonServers ?? []) - if !disabled.contains(name), - let entry = readProjectMCPFile(projectRoot: path)?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .project, projectPath: path) + if let index = config.servers.firstIndex(where: { $0.name == name }) { + let existing = config.servers[index] + record.projectOverrides = existing.projectOverrides.merging(record.projectOverrides) { _, new in new } + if scope == .user { + record.isGloballyEnabled = existing.isGloballyEnabled } + config.servers[index] = record + } else { + config.servers.append(record) } - if let entry = root?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .user, projectPath: nil) - } + try saveConfig(config) + } - throw MCPError.parseFailure("MCP server '\(name)' not found in any scope") + func remove(name: String, scope: MCPScope) async throws { + var config = try loadConfig() + config.servers.removeAll { $0.name == name } + try saveConfig(config) } - /// Lookup using known scope + project hints (from an existing `MCPServerInfo` row). - /// Falls back to the precedence-based `get` when hints don't resolve. - func get(name: String, scope: MCPScope?, projectPath: String?) async throws -> MCPServerDetail { - let root = readClaudeRoot() - switch scope { - case .user: - if let entry = root?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .user, projectPath: nil) - } - case .local: - if let projectPath, let entry = root?.projects?[projectPath]?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .local, projectPath: projectPath) - } - case .project: - if let projectPath, - let entry = readProjectMCPFile(projectRoot: projectPath)?.mcpServers?[name] { - return makeDetail(name: name, entry: entry, scope: .project, projectPath: projectPath) - } - case .none: - break + func setGlobalEnabled(name: String, enabled: Bool) async throws { + var config = try loadConfig() + guard let index = config.servers.firstIndex(where: { $0.name == name }) else { + throw MCPError.parseFailure("MCP server '\(name)' not found") } - return try await get(name: name, projectPath: projectPath) + config.servers[index].isGloballyEnabled = enabled + try saveConfig(config) } - // MARK: - Add / Remove + func setProjectOverride(name: String, projectPath: String, override: MCPProjectOverride) async throws { + var config = try loadConfig() + guard let index = config.servers.firstIndex(where: { $0.name == name }) else { + throw MCPError.parseFailure("MCP server '\(name)' not found") + } + if override == .inherit { + config.servers[index].projectOverrides.removeValue(forKey: projectPath) + } else { + config.servers[index].projectOverrides[projectPath] = override + } + try saveConfig(config) + } - /// Build and run `claude mcp add` for the supplied spec. - /// Falls back to `add-json` for stdio specs that include flag-like args - /// the parent shell would otherwise mangle. - func add(spec: MCPServerSpec, scope: MCPScope) async throws { - var args: [String] = ["mcp", "add", "-s", scope.rawValue] + // MARK: - Provider Config - switch spec.transport { - case .http, .sse: - args += ["-t", spec.transport.rawValue] - for header in spec.headers where !header.key.isEmpty { - args += ["-H", "\(header.key): \(header.value)"] - } - args += [spec.name, spec.url] + func writeClaudeConfig(projectPath: String?) async -> String? { + do { + let records = try enabledRecords(projectPath: projectPath) + let data = try JSONSerialization.data( + withJSONObject: ["mcpServers": dictionaryForClaude(records)], + options: [.prettyPrinted, .sortedKeys] + ) + return try writeGeneratedConfig(data: data, filename: "claude-mcp.json") + } catch { + logger.warning("Failed to write Claude MCP config: \(error.localizedDescription)") + return nil + } + } - case .stdio: - for kv in spec.env where !kv.key.isEmpty { - args += ["-e", "\(kv.key)=\(kv.value)"] + func codexConfigOverrides(projectPath: String?) async -> [String] { + do { + let config = try loadConfig() + var pairs: [String] = [] + for record in config.servers.sorted(by: { $0.name < $1.name }) { + // Always emit the full inline table. A bare `enabled=false` + // override fails codex's validation ("invalid transport") when + // the server isn't already defined in ~/.codex/config.toml. + let key = "mcp_servers.\(tomlKey(record.name))" + let enabled = record.isEnabled(for: projectPath) + pairs += ["-c", "\(key)=\(tomlInlineTable(for: record, enabled: enabled))"] } - args += [spec.name, "--", spec.command] - args += spec.args + return pairs + } catch { + logger.warning("Failed to build Codex MCP overrides: \(error.localizedDescription)") + return [] } - - _ = try await runClaude(args) } - func remove(name: String, scope: MCPScope) async throws { - _ = try await runClaude(["mcp", "remove", "-s", scope.rawValue, name]) + private func enabledRecords(projectPath: String?) throws -> [MCPServerRecord] { + try loadConfig().servers.filter { $0.isEnabled(for: projectPath) } } - // MARK: - Probe (test connection + list tools) + // MARK: - Probe - /// Probe an existing server by name. Resolves the configuration via `get` first. func probe(name: String, projectPath: String?) async -> MCPProbeResult { do { let detail = try await get(name: name, projectPath: projectPath) @@ -180,18 +178,15 @@ actor MCPService { } } - /// Probe an existing server identified by `MCPServerInfo` (carries scope + projectPath). - /// Preferred over `probe(name:projectPath:)` when the row's origin is known. func probe(info: MCPServerInfo) async -> MCPProbeResult { do { - let detail = try await get(name: info.name, scope: info.scope, projectPath: info.projectPath) + let detail = try await get(name: info.name, projectPath: info.projectPath) return await probe(detail: detail) } catch { return MCPProbeResult(ok: false, error: error.localizedDescription) } } - /// Probe a not-yet-saved spec (used during auto-probe on Save). func probe(spec: MCPServerSpec) async -> MCPProbeResult { let env = Dictionary(uniqueKeysWithValues: spec.env.map { ($0.key, $0.value) }) let headers = Dictionary(uniqueKeysWithValues: spec.headers.map { ($0.key, $0.value) }) @@ -217,88 +212,91 @@ actor MCPService { } } - // MARK: - Internal: claude CLI runner + // MARK: - RxCode Config - private func runClaude(_ args: [String]) async throws -> String { - guard let binary = await claudeService.findClaudeBinary() else { - throw MCPError.binaryNotFound - } - let env = await claudeService.resolvedEnvironment() - let result = try await runProcess( - executable: binary, - arguments: args, - environment: env - ) - guard result.status == 0 else { - let msg = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - throw MCPError.cliFailed(result.status, msg.isEmpty ? result.stdout : msg) - } - return result.stdout + private func configURL() -> URL { + AppSupport.bundleScopedURL.appendingPathComponent("mcp.json") } - private struct ProcessResult: Sendable { - let status: Int32 - let stdout: String - let stderr: String + private func generatedConfigDirectory() -> URL { + AppSupport.bundleScopedURL.appendingPathComponent("GeneratedMCP", isDirectory: true) } - private func runProcess( - executable: String, - arguments: [String], - environment: [String: String]?, - stdinData: Data? = nil, - currentDirectory: String? = nil - ) async throws -> ProcessResult { - let proc = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - let stdinPipe = Pipe() - - proc.executableURL = URL(fileURLWithPath: executable) - proc.arguments = arguments - proc.standardOutput = stdoutPipe - proc.standardError = stderrPipe - proc.standardInput = stdinPipe - if let environment { proc.environment = environment } - if let currentDirectory { - proc.currentDirectoryURL = URL(fileURLWithPath: currentDirectory) + private func loadConfig() throws -> MCPConfiguration { + let url = configURL() + if FileManager.default.fileExists(atPath: url.path) { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(MCPConfiguration.self, from: data) } + let imported = importExistingConfigs() + try saveConfig(imported) + return imported + } - try proc.run() + private func saveConfig(_ config: MCPConfiguration) throws { + let url = configURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(config) + try data.write(to: url, options: .atomic) + } - if let stdinData { - let handle = stdinPipe.fileHandleForWriting - try handle.write(contentsOf: stdinData) - try handle.close() - } else { - try stdinPipe.fileHandleForWriting.close() - } + private func writeGeneratedConfig(data: Data, filename: String) throws -> String { + let dir = generatedConfigDirectory() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let path = dir.appendingPathComponent(filename) + try data.write(to: path, options: .atomic) + return path.path + } - await withCheckedContinuation { (cont: CheckedContinuation) in - proc.terminationHandler = { _ in cont.resume() } - } + private func makeInfo(record: MCPServerRecord, projectPath: String?) -> MCPServerInfo { + MCPServerInfo( + name: record.name, + transport: record.transport, + endpoint: endpoint(from: record), + status: .unknown, + scope: .user, + projectPath: projectPath, + isGloballyEnabled: record.isGloballyEnabled, + projectOverride: record.projectOverride(for: projectPath), + effectiveEnabled: record.isEnabled(for: projectPath) + ) + } - let outData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let errData = stderrPipe.fileHandleForReading.readDataToEndOfFile() - return ProcessResult( - status: proc.terminationStatus, - stdout: String(data: outData, encoding: .utf8) ?? "", - stderr: String(data: errData, encoding: .utf8) ?? "" + private func makeDetail(record: MCPServerRecord, projectPath: String?) -> MCPServerDetail { + MCPServerDetail( + name: record.name, + scope: .user, + transport: record.transport, + url: record.transport == .stdio ? nil : record.url, + command: record.transport == .stdio ? record.command : nil, + args: record.args, + env: record.env, + headers: record.headers, + projectPath: projectPath ) } - // MARK: - On-disk config + private func endpoint(from record: MCPServerRecord) -> String { + switch record.transport { + case .http, .sse: + return record.url ?? "" + case .stdio: + let cmd = record.command ?? "" + return ([cmd] + record.args).filter { !$0.isEmpty }.joined(separator: " ") + } + } + + // MARK: - Imports - /// Minimal mirror of the `~/.claude.json` shape we care about. Anything - /// missing or malformed degrades to `nil` and is treated as "no servers". private struct ClaudeRootConfig: Decodable { var mcpServers: [String: ServerEntry]? - var projects: [String: ProjectEntry]? + var projects: [String: ClaudeProjectEntry]? } - private struct ProjectEntry: Decodable { + private struct ClaudeProjectEntry: Decodable { var mcpServers: [String: ServerEntry]? - var enabledMcpjsonServers: [String]? var disabledMcpjsonServers: [String]? } @@ -315,6 +313,100 @@ actor MCPService { var headers: [String: String]? } + private func importExistingConfigs() -> MCPConfiguration { + var recordsByName: [String: MCPServerRecord] = [:] + for record in importClaudeConfigs() + importCodexConfig() { + recordsByName[record.name] = merge(recordsByName[record.name], with: record) + } + return MCPConfiguration(servers: recordsByName.values.sorted { $0.name < $1.name }) + } + + private func merge(_ existing: MCPServerRecord?, with incoming: MCPServerRecord) -> MCPServerRecord { + guard var existing else { return incoming } + existing.transport = incoming.transport + existing.url = incoming.url + existing.command = incoming.command + existing.args = incoming.args + existing.env = incoming.env + existing.headers = incoming.headers + existing.cwd = incoming.cwd + existing.bearerTokenEnvVar = incoming.bearerTokenEnvVar + existing.isGloballyEnabled = existing.isGloballyEnabled || incoming.isGloballyEnabled + existing.projectOverrides.merge(incoming.projectOverrides) { _, new in new } + return existing + } + + private func importClaudeConfigs() -> [MCPServerRecord] { + guard let root = readJSON(ClaudeRootConfig.self, from: claudeRootPath()) else { return [] } + var records: [MCPServerRecord] = [] + + for (name, entry) in root.mcpServers ?? [:] { + records.append(record(name: name, entry: entry, isGloballyEnabled: true)) + } + + for (projectPath, project) in root.projects ?? [:] { + for (name, entry) in project.mcpServers ?? [:] { + var record = record(name: name, entry: entry, isGloballyEnabled: false) + record.projectOverrides[projectPath] = .enabled + records.append(record) + } + + if let projectFile = readJSON(ProjectMCPFile.self, from: projectMCPPath(projectPath)) { + let disabled = Set(project.disabledMcpjsonServers ?? []) + for (name, entry) in projectFile.mcpServers ?? [:] { + var record = record(name: name, entry: entry, isGloballyEnabled: false) + record.projectOverrides[projectPath] = disabled.contains(name) ? .disabled : .enabled + records.append(record) + } + } + } + + return records + } + + private func importCodexConfig() -> [MCPServerRecord] { + let path = (NSHomeDirectory() as NSString).appendingPathComponent(".codex/config.toml") + guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return [] } + + var records: [MCPServerRecord] = [] + var currentName: String? + var fields: [String: String] = [:] + + func flush() { + guard let name = currentName else { return } + let enabled = fields["enabled"].map { $0.trimmingCharacters(in: .whitespaces) != "false" } ?? true + if let url = fields["url"]?.tomlUnquoted { + records.append(MCPServerRecord(name: name, transport: .http, url: url, isGloballyEnabled: enabled)) + } else if let command = fields["command"]?.tomlUnquoted { + let args = fields["args"]?.tomlArrayValues ?? [] + let cwd = fields["cwd"]?.tomlUnquoted + records.append(MCPServerRecord(name: name, transport: .stdio, command: command, args: args, cwd: cwd, isGloballyEnabled: enabled)) + } + } + + for rawLine in text.split(whereSeparator: \.isNewline).map(String.init) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard !line.isEmpty, !line.hasPrefix("#") else { continue } + if line.hasPrefix("[mcp_servers."), line.hasSuffix("]") { + flush() + let name = String(line.dropFirst("[mcp_servers.".count).dropLast()) + currentName = name.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + fields = [:] + } else if currentName != nil, let equal = line.firstIndex(of: "=") { + let key = String(line[..(_ type: T.Type, from path: String) -> T? { + guard let data = FileManager.default.contents(atPath: path) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + private func claudeRootPath() -> String { (NSHomeDirectory() as NSString).appendingPathComponent(".claude.json") } @@ -323,61 +415,93 @@ actor MCPService { (projectRoot as NSString).appendingPathComponent(".mcp.json") } - private func readClaudeRoot() -> ClaudeRootConfig? { - guard let data = FileManager.default.contents(atPath: claudeRootPath()) else { return nil } - return try? JSONDecoder().decode(ClaudeRootConfig.self, from: data) - } - - private func readProjectMCPFile(projectRoot: String) -> ProjectMCPFile? { - guard let data = FileManager.default.contents(atPath: projectMCPPath(projectRoot)) else { - return nil - } - return try? JSONDecoder().decode(ProjectMCPFile.self, from: data) + private func record(name: String, entry: ServerEntry, isGloballyEnabled: Bool) -> MCPServerRecord { + let transport = transport(from: entry) + return MCPServerRecord( + name: name, + transport: transport, + url: transport == .stdio ? nil : entry.url, + command: transport == .stdio ? entry.command : nil, + args: entry.args ?? [], + env: entry.env ?? [:], + headers: entry.headers ?? [:], + isGloballyEnabled: isGloballyEnabled + ) } private func transport(from entry: ServerEntry) -> MCPTransport { switch entry.type?.lowercased() { - case "http": return .http - case "sse": return .sse - default: return .stdio + case "http", "streamable_http": return .http + case "sse": return .sse + default: return .stdio } } - private func endpoint(from entry: ServerEntry) -> String { - switch transport(from: entry) { - case .http, .sse: - return entry.url ?? "" + // MARK: - Provider Serialization + + private func dictionaryForClaude(_ records: [MCPServerRecord]) -> [String: [String: Any]] { + Dictionary(uniqueKeysWithValues: records.map { record in + var entry: [String: Any] = ["type": record.transport.rawValue] + switch record.transport { + case .stdio: + entry["command"] = record.command ?? "" + if !record.args.isEmpty { entry["args"] = record.args } + if !record.env.isEmpty { entry["env"] = record.env } + case .http, .sse: + entry["url"] = record.url ?? "" + if !record.headers.isEmpty { entry["headers"] = record.headers } + } + return (record.name, entry) + }) + } + + private func tomlInlineTable(for record: MCPServerRecord, enabled: Bool = true) -> String { + var parts = ["enabled=\(enabled ? "true" : "false")"] + switch record.transport { case .stdio: - let cmd = entry.command ?? "" - let args = entry.args ?? [] - return ([cmd] + args).filter { !$0.isEmpty }.joined(separator: " ") + parts.append("command=\(tomlString(record.command ?? ""))") + if !record.args.isEmpty { + parts.append("args=\(tomlArray(record.args))") + } + if !record.env.isEmpty { + parts.append("env=\(tomlDictionary(record.env))") + } + if let cwd = record.cwd, !cwd.isEmpty { + parts.append("cwd=\(tomlString(cwd))") + } + case .http, .sse: + parts.append("url=\(tomlString(record.url ?? ""))") + if let bearer = record.bearerTokenEnvVar, !bearer.isEmpty { + parts.append("bearer_token_env_var=\(tomlString(bearer))") + } } + return "{\(parts.joined(separator: ","))}" } - private func makeInfo(name: String, entry: ServerEntry, scope: MCPScope, projectPath: String?) -> MCPServerInfo { - MCPServerInfo( - name: name, - transport: transport(from: entry), - endpoint: endpoint(from: entry), - status: .unknown, - scope: scope, - projectPath: projectPath - ) + private func tomlKey(_ key: String) -> String { + if key.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil { + return key + } + return tomlString(key) } - private func makeDetail(name: String, entry: ServerEntry, scope: MCPScope, projectPath: String?) -> MCPServerDetail { - let t = transport(from: entry) - return MCPServerDetail( - name: name, - scope: scope, - transport: t, - url: (t == .stdio) ? nil : entry.url, - command: (t == .stdio) ? entry.command : nil, - args: entry.args ?? [], - env: entry.env ?? [:], - headers: entry.headers ?? [:], - projectPath: projectPath - ) + private func tomlString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" + } + + private func tomlArray(_ values: [String]) -> String { + "[\(values.map(tomlString).joined(separator: ","))]" + } + + private func tomlDictionary(_ values: [String: String]) -> String { + let parts = values.keys.sorted().map { key in + "\(tomlKey(key))=\(tomlString(values[key] ?? ""))" + } + return "{\(parts.joined(separator: ","))}" } // MARK: - Probe: stdio @@ -411,14 +535,11 @@ actor MCPService { let stdinHandle = stdinPipe.fileHandleForWriting let stdoutHandle = stdoutPipe.fileHandleForReading - // Pre-build payloads outside the @Sendable closure so it doesn't need - // to capture `self`. The closure can then reach back via `self.parse…`. let initializeJSON = initializeRequest(id: 1) let initializedJSON = initializedNotification() let toolsListJSON = toolsListRequest(id: 2) let result = await withTimeout(seconds: 10) { [self] () -> MCPProbeResult in - // 1. initialize do { try Self.writeJSONRPC(initializeJSON, to: stdinHandle) } catch { @@ -430,15 +551,8 @@ actor MCPService { } let (serverName, serverVersion) = self.parseInitializeResponse(initReply) - // 2. notifications/initialized do { try Self.writeJSONRPC(initializedJSON, to: stdinHandle) - } catch { - return MCPProbeResult(ok: false, error: "stdin write failed: \(error.localizedDescription)") - } - - // 3. tools/list - do { try Self.writeJSONRPC(toolsListJSON, to: stdinHandle) } catch { return MCPProbeResult(ok: false, error: "stdin write failed: \(error.localizedDescription)") @@ -462,7 +576,6 @@ actor MCPService { ) } - // Tear down — SIGTERM then SIGKILL after 2s. if proc.isRunning { proc.terminate() let pid = proc.processIdentifier @@ -475,8 +588,6 @@ actor MCPService { return result ?? MCPProbeResult(ok: false, error: MCPError.probeTimeout.errorDescription) } - /// Probe a stdio command name (e.g. `npx`) by walking PATH from the resolved env. - /// Returns the absolute executable path, or nil if not found / already absolute. private func resolveCommand(_ command: String, env: [String: String]) -> String? { if command.contains("/") { return FileManager.default.isExecutableFile(atPath: command) ? command : nil @@ -537,8 +648,6 @@ actor MCPService { } } - /// One round-trip of MCP-over-HTTP (Streamable HTTP variant). - /// Returns the raw response body, response, and any new MCP-Session-Id. private func postMCP( url: URL, body: [String: Any], @@ -562,13 +671,11 @@ actor MCPService { return (data, response, returnedSession ?? sessionID) } - /// Pull the JSON-RPC payload out of either a plain JSON body or an SSE stream. private func extractJSON(_ data: Data) -> [String: Any]? { if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return obj } guard let text = String(data: data, encoding: .utf8) else { return nil } - // SSE: look for lines starting with "data: " for rawLine in text.split(whereSeparator: \.isNewline) { let line = String(rawLine) guard line.hasPrefix("data: ") else { continue } @@ -624,64 +731,70 @@ actor MCPService { private nonisolated func parseToolsListResponse(_ json: [String: Any]?) -> [MCPTool] { guard let result = json?["result"] as? [String: Any], - let raw = result["tools"] as? [[String: Any]] else { return [] } - return raw.compactMap { dict in + let tools = result["tools"] as? [[String: Any]] else { + return [] + } + return tools.compactMap { dict in guard let name = dict["name"] as? String else { return nil } return MCPTool(name: name, description: dict["description"] as? String) } } - // MARK: - JSON line I/O for stdio probe - - private static func writeJSONRPC(_ obj: [String: Any], to handle: FileHandle) throws { - let data = try JSONSerialization.data(withJSONObject: obj) - try handle.write(contentsOf: data) - try handle.write(contentsOf: Data([0x0A])) - } - - /// Read one newline-delimited JSON object from the handle. - /// Returns nil on EOF or parse failure. - private static func readJSONLine(from handle: FileHandle) async -> [String: Any]? { - await withCheckedContinuation { (cont: CheckedContinuation<[String: Any]?, Never>) in - DispatchQueue.global(qos: .userInitiated).async { - var buffer = Data() - while true { - let chunk = handle.availableData - if chunk.isEmpty { - cont.resume(returning: nil) - return - } - buffer.append(chunk) - while let nlIdx = buffer.firstIndex(of: 0x0A) { - let lineData = buffer.subdata(in: 0.. [String: Any]? { + await Task.detached { + let data = handle.availableData + guard !data.isEmpty, + let text = String(data: data, encoding: .utf8), + let line = text.split(whereSeparator: \.isNewline).first else { + return nil + } + let lineData = Data(String(line).utf8) + return (try? JSONSerialization.jsonObject(with: lineData)) as? [String: Any] + }.value + } private func withTimeout( - seconds: Double, + seconds: UInt64, operation: @escaping @Sendable () async -> T ) async -> T? { await withTaskGroup(of: T?.self) { group in group.addTask { await operation() } group.addTask { - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + try? await Task.sleep(nanoseconds: seconds * 1_000_000_000) return nil } - let first = await group.next() ?? nil + let result = await group.next() ?? nil group.cancelAll() - return first + return result } } } + +private extension String { + nonisolated var tomlUnquoted: String { + var value = trimmingCharacters(in: .whitespaces) + if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 { + value = String(value.dropFirst().dropLast()) + } + return value + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\\\", with: "\\") + } + + nonisolated var tomlArrayValues: [String]? { + let value = trimmingCharacters(in: .whitespaces) + guard value.hasPrefix("["), value.hasSuffix("]") else { return nil } + let body = value.dropFirst().dropLast() + return body + .split(separator: ",") + .map { String($0).tomlUnquoted } + .filter { !$0.isEmpty } + } +} diff --git a/RxCode/Views/Settings/MCPSettingsTab.swift b/RxCode/Views/Settings/MCPSettingsTab.swift index dd47ea55..18c953c0 100644 --- a/RxCode/Views/Settings/MCPSettingsTab.swift +++ b/RxCode/Views/Settings/MCPSettingsTab.swift @@ -16,9 +16,7 @@ struct MCPSettingsTab: View { if let error = appState.mcpListError { errorBanner(error) } - ForEach(visibleScopes) { scope in - scopeSection(scope: scope) - } + serverSection } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) @@ -49,7 +47,7 @@ struct MCPSettingsTab: View { } } } message: { server in - Text(verbatim: "“\(server.name)” will be removed from the \(server.scope?.displayName ?? "User") scope.") + Text(verbatim: "“\(server.name)” will be removed from RxCode MCP settings.") } .alert("MCP error", isPresented: actionErrorBinding, presenting: actionError) { _ in Button("OK", role: .cancel) { actionError = nil } @@ -65,7 +63,7 @@ struct MCPSettingsTab: View { VStack(alignment: .leading, spacing: 2) { Text("MCP Servers") .font(.system(size: ClaudeTheme.size(15), weight: .semibold)) - Text("Configure Model Context Protocol servers used by Claude Code.") + Text("Configure Model Context Protocol servers used by Claude Code and Codex.") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } @@ -106,22 +104,6 @@ struct MCPSettingsTab: View { // MARK: - Context filtering - /// When a project is active, only project-scoped MCPs (Local/Project for the - /// current path) are shown. Otherwise, only User-scope (global) MCPs are shown. - private var visibleScopes: [MCPScope] { - appState.activeProjectPath != nil ? [.local, .project] : [.user] - } - - private func servers(in scope: MCPScope) -> [MCPServerInfo] { - appState.mcpServers.filter { server in - guard (server.scope ?? .user) == scope else { return false } - if let activePath = appState.activeProjectPath { - return server.projectPath == activePath - } - return true - } - } - @ViewBuilder private var contextBanner: some View { if let path = appState.activeProjectPath, !path.isEmpty { @@ -129,7 +111,7 @@ struct MCPSettingsTab: View { Image(systemName: "folder") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) - Text("Showing MCPs for this project") + Text("Showing effective MCP state for this project") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) Text(verbatim: displayPath(path)) @@ -143,7 +125,7 @@ struct MCPSettingsTab: View { Image(systemName: "globe") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) - Text("Showing global MCPs only") + Text("Showing global MCP defaults") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } @@ -158,20 +140,19 @@ struct MCPSettingsTab: View { return path } - // MARK: - Scope section + // MARK: - Server section - private func scopeSection(scope: MCPScope) -> some View { - let servers = servers(in: scope) - return VStack(alignment: .leading, spacing: 8) { + private var serverSection: some View { + VStack(alignment: .leading, spacing: 8) { HStack(alignment: .lastTextBaseline, spacing: 8) { - Text(LocalizedStringKey(scope.displayName)) + Text("Servers") .font(.system(size: ClaudeTheme.size(13), weight: .semibold)) - Text(LocalizedStringKey(scope.subtitle)) + Text("Global default plus per-project override") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) } - if servers.isEmpty { + if appState.mcpServers.isEmpty { Text("No servers") .font(.system(size: ClaudeTheme.size(11))) .foregroundStyle(.secondary) @@ -182,12 +163,27 @@ struct MCPSettingsTab: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } else { VStack(spacing: 6) { - ForEach(servers) { server in + ForEach(appState.mcpServers) { server in MCPServerRow( server: server, + projectPath: appState.activeProjectPath, probe: appState.mcpProbeResults[server.id], isProbing: appState.mcpInFlightProbes.contains(server.id), onTest: { Task { await appState.probeMCPServer(info: server) } }, + onGlobalEnabledChange: { enabled in + Task { + if let err = await appState.setMCPServerGlobalEnabled(name: server.name, enabled: enabled) { + actionError = err + } + } + }, + onProjectOverrideChange: { override in + Task { + if let err = await appState.setMCPServerProjectOverride(name: server.name, override: override) { + actionError = err + } + } + }, onRemove: { pendingRemoval = server } ) } @@ -217,63 +213,88 @@ struct MCPSettingsTab: View { private struct MCPServerRow: View { let server: MCPServerInfo + let projectPath: String? let probe: MCPProbeResult? let isProbing: Bool let onTest: () -> Void + let onGlobalEnabledChange: (Bool) -> Void + let onProjectOverrideChange: (MCPProjectOverride) -> Void let onRemove: () -> Void @State private var expanded = false + private var inProject: Bool { projectPath != nil } + var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 10) { + HStack(spacing: 12) { statusDot VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Text(verbatim: server.name) .font(.system(size: ClaudeTheme.size(13), weight: .medium)) transportBadge + if inProject && server.projectOverride != .inherit { + overrideBadge + } } Text(verbatim: server.endpoint) - .font(.system(size: ClaudeTheme.size(11))) + .font(.system(size: ClaudeTheme.size(11), design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) - if let projectPath = server.projectPath, !projectPath.isEmpty { - Text(verbatim: projectDisplayPath(projectPath)) - .font(.system(size: ClaudeTheme.size(10))) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.middle) - } + Text(verbatim: scopeDescription) + .font(.system(size: ClaudeTheme.size(10))) + .foregroundStyle(.tertiary) } Spacer(minLength: 12) - Button(action: onTest) { - if isProbing { - ProgressView().controlSize(.small) - } else { - Text("Test") - .font(.system(size: ClaudeTheme.size(11))) - } + if isProbing { + ProgressView().controlSize(.small) } - .buttonStyle(.bordered) - .disabled(isProbing) + + Toggle("", isOn: effectiveBinding) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .help(toggleHelp) Menu { - Button("Reconnect", action: onTest) - if probe != nil { - Button(expanded ? "Hide Tools" : "View Tools") { expanded.toggle() } + Button(isProbing ? "Testing…" : "Test connection", action: onTest) + .disabled(isProbing || !server.effectiveEnabled) + if let probe, !probe.tools.isEmpty || probe.error != nil { + Button(expanded ? "Hide details" : "View details") { expanded.toggle() } } + + if inProject { + Divider() + Section("This project") { + Picker("Override", selection: overrideBinding) { + Text("Inherit global default").tag(MCPProjectOverride.inherit) + Text("Force on").tag(MCPProjectOverride.enabled) + Text("Force off").tag(MCPProjectOverride.disabled) + } + .pickerStyle(.inline) + .labelsHidden() + } + Section("Global default") { + Button(server.isGloballyEnabled ? "Disable globally" : "Enable globally") { + onGlobalEnabledChange(!server.isGloballyEnabled) + } + } + } + Divider() - Button("Remove", role: .destructive, action: onRemove) + Button("Remove…", role: .destructive, action: onRemove) } label: { Image(systemName: "ellipsis") .font(.system(size: ClaudeTheme.size(12))) + .frame(width: 28, height: 24) + .contentShape(Rectangle()) } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .frame(width: 28, height: 24) + .fixedSize() } .padding(.horizontal, 12) .padding(.vertical, 10) @@ -293,6 +314,66 @@ private struct MCPServerRow: View { ) } + // MARK: - Bindings + + private var effectiveBinding: Binding { + Binding( + get: { server.effectiveEnabled }, + set: { newValue in + if inProject { + onProjectOverrideChange(newValue ? .enabled : .disabled) + } else { + onGlobalEnabledChange(newValue) + } + } + ) + } + + private var overrideBinding: Binding { + Binding( + get: { server.projectOverride }, + set: { onProjectOverrideChange($0) } + ) + } + + // MARK: - Labels + + private var toggleHelp: String { + if inProject { + return server.effectiveEnabled + ? "Turn off for this project" + : "Turn on for this project" + } + return server.effectiveEnabled ? "Disable globally" : "Enable globally" + } + + private var scopeDescription: String { + guard inProject else { + return server.isGloballyEnabled ? "Global default · On" : "Global default · Off" + } + switch server.projectOverride { + case .inherit: + return server.isGloballyEnabled + ? "Inherits global default (On)" + : "Inherits global default (Off)" + case .enabled: + return "Forced on for this project" + case .disabled: + return "Forced off for this project" + } + } + + private var overrideBadge: some View { + Text(server.projectOverride == .enabled ? "Project: On" : "Project: Off") + .font(.system(size: ClaudeTheme.size(10), weight: .semibold)) + .foregroundStyle(.orange) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .help("Project-specific override is set") + } + private var statusDot: some View { let (color, label) = statusColorAndLabel return Circle() @@ -310,14 +391,6 @@ private struct MCPServerRow: View { } } - private func projectDisplayPath(_ path: String) -> String { - let home = NSHomeDirectory() - if path.hasPrefix(home) { - return "~" + String(path.dropFirst(home.count)) - } - return path - } - private var transportBadge: some View { Text(verbatim: server.transport.displayName) .font(.system(size: ClaudeTheme.size(10), weight: .medium)) diff --git a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift index 57e17c42..f849f99f 100644 --- a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift +++ b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift @@ -2,12 +2,12 @@ import SwiftUI import RxCodeCore import RxCodeChatKit -/// Toolbar pill rendering the current session's TodoWrite progress. +/// Toolbar pill rendering the current session's todo-style progress. /// -/// Hidden until the active CLI session emits at least one `TodoWrite` tool -/// call. Live updates come from in-memory messages; a SwiftData-backed -/// snapshot fills in for cold opens before message replay completes. Tap -/// toggles a popover listing each todo with status icon. +/// Hidden until the active CLI session emits progress. Live `TodoWrite` +/// updates come from in-memory messages; a SwiftData-backed snapshot fills in +/// for cold opens and Codex plan updates. Tap toggles a popover listing each +/// todo with status icon. struct TodoProgressToolbarItem: View { @Environment(AppState.self) private var appState @Environment(WindowState.self) private var windowState