diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2ee77697..4e1c56b7 100644 --- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2a25e60d59e012004c2de1aa5bf8f77cbe5787aebfadc280bb28549e57ca69a1", + "originHash" : "50bc054679eb41a28514dc07979bbfd75d2474ab3096c75e489fd93ab701012a", "pins" : [ { "identity" : "abseil-cpp-binary", diff --git a/RxCode/App/AppState+CrossProject.swift b/RxCode/App/AppState+CrossProject.swift index 3d49a147..c261e4a3 100644 --- a/RxCode/App/AppState+CrossProject.swift +++ b/RxCode/App/AppState+CrossProject.swift @@ -259,17 +259,52 @@ extension AppState { } } + // Kick off the expensive, independent pre-spawn work concurrently. + // Memory lookup, git branch + briefing, IDE-MCP port allocation, and + // skill-context resolution all hop off MainActor and previously ran + // stacked serially — pushing the first stream event well after the + // user pressed send. Each `async let` starts immediately and is only + // joined when its value is read below. + // + // Snapshot the MainActor flags into locals first: `async let` evaluates + // the right-hand side in a nonisolated autoclosure, so it can't read + // `memoryEnabled` / `memoryInjectEnabled` / `memoryMaxContextItems` + // directly. The local `let` capture solves both isolation and the + // "captured var" warning for `sessionKey`. + let memoryActive = memoryEnabled && memoryInjectEnabled + let memoryLimit = memoryMaxContextItems + let capturedSessionKey = sessionKey + let memoryService = self.memoryService + let marketplace = self.marketplace + let ideMCPServer = self.ideMCPServer + + async let memoryItemsAsync: [MemoryItem] = memoryActive + ? await systemPromptMemoryItems(projectId: projectId, provider: agentProvider, model: model) + : [] + async let memoryHitsAsync = memoryActive + ? await memoryService.search(prompt, projectId: projectId, limit: memoryLimit) + : [] + async let currentBranchAsync = GitHelper.currentBranch(at: cwd) + async let idePortAsync = ideMCPServer.allocate( + sessionKey: capturedSessionKey, + capabilities: agentProvider.staticCapabilities + ) + async let skillContextAsync: String? = marketplace.promptContext(for: agentProvider) + async let codexSkillOverridesAsync: [String] = agentProvider == .codex + ? await marketplace.codexConfigOverrides() + : [] + let resolvedMemoryContext: String - if memoryEnabled, memoryInjectEnabled { - let systemItems = await systemPromptMemoryItems(projectId: projectId, provider: agentProvider, model: model) - let hits = await memoryService.search(prompt, projectId: projectId, limit: memoryMaxContextItems) + if memoryActive { + let systemItems = await memoryItemsAsync + let hits = await memoryHitsAsync resolvedMemoryContext = memoryContextSystemPrompt(systemItems: systemItems, relatedHits: hits) } else { resolvedMemoryContext = "" } let branchBriefingContext: String - if let branch = await GitHelper.currentBranch(at: cwd), + if let branch = await currentBranchAsync, let briefing = threadStore.branchBriefingItem(projectId: projectId, branch: branch) { branchBriefingContext = Self.branchBriefingSystemPrompt( branch: branch, @@ -279,55 +314,35 @@ extension AppState { branchBriefingContext = "" } + // The IDE-MCP port is provider-agnostic at allocation time — the + // bridge command is built from the port. Per-backend MCP config + // writes still happen serially after this since they consume the + // bridge, but they no longer block memory/git/skill resolution. + let idePort = await idePortAsync + let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } + switch agentProvider { case .claudeCode: - // Allocate a per-session IDE-MCP port so the Claude agent can call - // IDE-only tools — cross-project chat (`ide__send_to_thread`), - // thread history, running jobs, usage. The bridge is a perl - // one-liner Claude runs as the `rxcode-ide` MCP server child. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.claudeCode.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } mcpClaudeConfigPath = await mcp.writeClaudeConfig(projectPath: cwd, bridgeCommand: bridge) // Surface the accumulated briefing for the project's current branch // to the agent as background context via `--append-system-prompt`. appendExtraSystemPrompt(branchBriefingContext) appendExtraSystemPrompt(resolvedMemoryContext) - if let skillContext = await marketplace.promptContext(for: .claudeCode) { + if let skillContext = await skillContextAsync { appendExtraSystemPrompt(skillContext) } case .codex: - // Allocate a per-session IDE-MCP port so the Codex agent can call - // IDE-only tools — cross-project chat, thread history, running - // jobs, usage, durable memory. The bridge is a perl one-liner - // Codex runs as the `rxcode-ide` stdio MCP server child. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.codex.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } mcpCodexOverrides = await mcp.codexConfigOverrides(projectPath: cwd, bridgeCommand: bridge) - mcpCodexOverrides += await marketplace.codexConfigOverrides() + mcpCodexOverrides += await codexSkillOverridesAsync resolvedPrompt = Self.promptWithBackgroundContext( [branchBriefingContext, resolvedMemoryContext], prompt: resolvedPrompt ) - if let skillContext = await marketplace.promptContext(for: .codex) { + if let skillContext = await skillContextAsync { resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" } resolvedSendMode = registerMode case .acp: - // Allocate a per-session IDE-MCP port so the ACP agent can call - // polyfill / introspection tools. The agent's MCP child is a - // perl one-liner that bridges its stdio to our TCP listener; - // the listener stays bound to this session for its lifetime. - let idePort = await ideMCPServer.allocate( - sessionKey: sessionKey, - capabilities: AgentProvider.acp.staticCapabilities - ) - let bridge = idePort.map { IDEMCPServer.bridgeCommand(forPort: $0) } acpMCPServers = await mcp.acpMCPServers( projectPath: cwd, bridgeCommand: bridge @@ -336,7 +351,7 @@ extension AppState { [branchBriefingContext, resolvedMemoryContext], prompt: resolvedPrompt ) - if let skillContext = await marketplace.promptContext(for: .acp) { + if let skillContext = await skillContextAsync { resolvedPrompt = "\(skillContext)\n\nUser request:\n\(resolvedPrompt)" } // `model` may be a composite `::` key (from the picker) @@ -884,6 +899,7 @@ extension AppState { "[TodoSnapshot] session=\(targetSession, privacy: .public) total=\(snapshot.items.count) done=\(done) active=\(active, privacy: .public)" ) threadStore.upsertTodoSnapshot(sessionId: targetSession, items: snapshot.items) + todoSnapshotsRevision &+= 1 broadcastMobileSessionStatus(sessionID: targetSession) case .acpModelsDiscovered(let event): diff --git a/RxCode/App/AppState+Lifecycle.swift b/RxCode/App/AppState+Lifecycle.swift index c7dbb625..a6350faa 100644 --- a/RxCode/App/AppState+Lifecycle.swift +++ b/RxCode/App/AppState+Lifecycle.swift @@ -147,6 +147,18 @@ extension AppState { await refreshAgentInstallations() + // Prewarm each backend's shell PATH cache in parallel so the first + // user message in a thread doesn't pay the `/bin/zsh -ilc` round trip + // on its critical path. The result is cached inside each actor, so + // every subsequent spawn reuses it. + Task { [claude, codex, acp] in + await withTaskGroup(of: Void.self) { group in + group.addTask { await claude.prewarm() } + group.addTask { await codex.prewarm() } + group.addTask { await acp.prewarm() } + } + } + projects = await persistence.loadProjects() var seenPaths = Set() let deduplicated = projects.filter { seenPaths.insert($0.path).inserted } diff --git a/RxCode/App/AppState+Stream.swift b/RxCode/App/AppState+Stream.swift index 2ca7433f..6e5604db 100644 --- a/RxCode/App/AppState+Stream.swift +++ b/RxCode/App/AppState+Stream.swift @@ -394,6 +394,7 @@ extension AppState { "[TodoWrite] session=\(sessionKey, privacy: .public) total=\(todos.count) done=\(done) active=\(active, privacy: .public)" ) threadStore.upsertTodoSnapshot(sessionId: sessionKey, items: todos) + todoSnapshotsRevision &+= 1 } } } diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 689bc455..948e4295 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -208,6 +208,13 @@ final class AppState { /// `threadFileEdits(in:)` fetch after a new Edit/Write tool call lands. var threadFileEditsRevision: Int = 0 + /// Bumped each time a todo snapshot row is upserted in SwiftData (MCP + /// `ide__set_todos`, Codex `.todoSnapshot` events, in-message TodoWrite + /// persistence). The toolbar's `TodoProgressToolbarItem` reads this so + /// SwiftUI re-runs the `fetchTodoSnapshot` method when the snapshot + /// changes without an accompanying observable property mutation. + var todoSnapshotsRevision: Int = 0 + /// Pending permission/question prompts keyed by hook id. This mirrors the /// per-window queues so mobile thread rows can show the same attention state. var mobilePendingRequests: [String: PermissionRequest] = [:] diff --git a/RxCode/Services/ACPService+Spawn.swift b/RxCode/Services/ACPService+Spawn.swift index cef38d65..aa1c8ab5 100644 --- a/RxCode/Services/ACPService+Spawn.swift +++ b/RxCode/Services/ACPService+Spawn.swift @@ -63,6 +63,12 @@ extension ACPService { return env } + /// Prime the shell PATH cache so the first user message doesn't pay the + /// `/bin/zsh -ilc` round trip in its critical path. + func prewarm() async { + _ = await resolvedEnvironment() + } + func readUserShellPath() async -> String? { await Task.detached(priority: .userInitiated) { let process = Process() diff --git a/RxCode/Services/ClaudeService+Discovery.swift b/RxCode/Services/ClaudeService+Discovery.swift index 0c1458ca..5079eaa2 100644 --- a/RxCode/Services/ClaudeService+Discovery.swift +++ b/RxCode/Services/ClaudeService+Discovery.swift @@ -92,6 +92,13 @@ extension ClaudeCodeServer { return env } + /// Prime the shell PATH cache so the first user message doesn't pay the + /// `/bin/zsh -ilc` round trip in its critical path. Safe to call multiple + /// times — `resolvedShellPath()` is idempotent. + func prewarm() async { + _ = await resolvedShellPath() + } + // MARK: - Binary Discovery /// Well-known paths searched in order before falling back to the shell. diff --git a/RxCode/Services/CodexAppServer+Process.swift b/RxCode/Services/CodexAppServer+Process.swift index 37920c9d..c594cb93 100644 --- a/RxCode/Services/CodexAppServer+Process.swift +++ b/RxCode/Services/CodexAppServer+Process.swift @@ -125,6 +125,12 @@ extension CodexAppServer { return env } + /// Prime the shell PATH cache so the first user message doesn't pay the + /// `/bin/zsh -ilc` round trip in its critical path. + func prewarm() async { + _ = await resolvedEnvironment() + } + func runShellCommand(_ executable: String, arguments: [String], injectPath: Bool = true) async throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: executable) diff --git a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift index d6832e8b..b1586f08 100644 --- a/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift +++ b/RxCode/Services/IDEServer/AppState+IDEToolHandling.swift @@ -79,6 +79,7 @@ extension AppState: IDEToolHandling { return TodoItem(id: idx, content: content, activeForm: activeForm, status: status) } threadStore.upsertTodoSnapshot(sessionId: sessionKey, items: parsed) + todoSnapshotsRevision &+= 1 return textResult("Recorded \(parsed.count) todo(s).") } diff --git a/RxCode/Services/PermissionServer.swift b/RxCode/Services/PermissionServer.swift index 4ef73dbe..84d7b138 100644 --- a/RxCode/Services/PermissionServer.swift +++ b/RxCode/Services/PermissionServer.swift @@ -14,7 +14,6 @@ actor PermissionServer { private static let basePort: UInt16 = 19836 private static let maxPort: UInt16 = 19846 - private static let timeoutSeconds: UInt64 = 300 // 5 minutes /// Upper bound on simultaneously-valid hook tokens. Each CLI launch mints one; /// the oldest is evicted past this cap — generous enough that no realistic /// number of concurrent agents ever loses a still-live token. @@ -55,7 +54,6 @@ actor PermissionServer { var reasonOverride: String? } private var pending: [String: Pending] = [:] - private var timeoutTasks: [String: Task] = [:] /// In-memory only; cleared on `stop()`. private var sessionToolAllows: Set = [] @@ -163,8 +161,6 @@ actor PermissionServer { } } pending.removeAll() - timeoutTasks.values.forEach { $0.cancel() } - timeoutTasks.removeAll() for continuation in subscribers.values { continuation.finish() } @@ -186,8 +182,6 @@ actor PermissionServer { /// Called by the UI when the user makes a decision. func respond(toolUseId: String, decision: PermissionDecision) async { - timeoutTasks.removeValue(forKey: toolUseId)?.cancel() - guard var entry = pending.removeValue(forKey: toolUseId) else { logger.warning("No pending continuation for toolUseId \(toolUseId)") return @@ -438,8 +432,11 @@ actor PermissionServer { } } - /// Wait for a UI decision with a 5-minute timeout. - /// The first requester emits to the UI stream and sets the timeout. CLI retries join the same entry. + /// Wait for a UI decision. The pending entry persists indefinitely until the + /// user responds via `respond(...)` (or the listener is torn down in `stop()`). + /// The first requester emits to the UI stream; CLI retries — issued every time the + /// CLI's own HTTP hook timeout expires — join the same entry by appending another + /// continuation, so the eventual answer reaches whichever HTTP connection is live. private func waitForDecision( toolUseId: String, sessionId: String?, @@ -467,14 +464,8 @@ actor PermissionServer { updatedInput: nil, reasonOverride: nil ) - timeoutTasks[toolUseId] = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(Self.timeoutSeconds) * 1_000_000_000) - guard !Task.isCancelled else { return } - await self?.cancelPendingIfNeeded(toolUseId: toolUseId) - } } } - timeoutTasks.removeValue(forKey: toolUseId)?.cancel() return outcome } @@ -483,14 +474,6 @@ actor PermissionServer { return normalized == "exitplanmode" || normalized == "exit_plan_mode" } - /// Remove and resume all pending continuations with .deny (timeout case). - private func cancelPendingIfNeeded(toolUseId: String) { - guard let entry = pending.removeValue(forKey: toolUseId) else { return } - for continuation in entry.continuations { - continuation.resume(returning: DecisionOutcome(decision: .deny, updatedInput: nil, reasonOverride: nil)) - } - } - private func sendHookResponse( _ connection: NWConnection, decision: String, diff --git a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift index 3cf33a50..4c8967a1 100644 --- a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift +++ b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift @@ -22,6 +22,9 @@ struct TodoProgressToolbarItem: View { let live = TodoExtractor.latest(in: messages) { return live } + // Touch the revision so SwiftUI re-runs this fetch when a new snapshot + // is upserted via MCP `ide__set_todos` or a Codex `.todoSnapshot` event. + _ = appState.todoSnapshotsRevision return appState.threadStore.fetchTodoSnapshot(sessionId: sessionKey)?.items } diff --git a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt index 0e04e2bf..aa2be393 100644 --- a/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt +++ b/RxCodeAndroid/app/src/main/java/app/rxlab/rxcode/state/MobileAppState.kt @@ -249,7 +249,24 @@ class MobileAppState @Inject constructor( current.copy( projects = snap.data.projects, sessions = snap.data.sessions.sortedWith(SessionSort), - activeSessionID = active ?: current.activeSessionID, + // The snapshot's activeSessionID is metadata that labels the + // carried activeSessionMessages — it must not be treated as a + // navigation command. Only adopt it when our local selection + // is the optimistic draft id produced by startNewSession and + // the redirect map (set up above) confirms this snapshot is + // the desktop's reply assigning the real id. Any other case + // (reconnect, periodic refresh, desktop-focus change) leaves + // the user's current selection alone. + activeSessionID = if ( + active != null && + currentActive != null && + isDraftSessionId(currentActive) && + nextRedirects[currentActive] == active + ) { + active + } else { + current.activeSessionID + }, sessionIDRedirects = nextRedirects, messagesBySession = nextMessages, sessionsWithMoreMessages = moreSet, diff --git a/RxCodeMobile/State/MobileAppState+Inbound.swift b/RxCodeMobile/State/MobileAppState+Inbound.swift index d9652913..552c8f39 100644 --- a/RxCodeMobile/State/MobileAppState+Inbound.swift +++ b/RxCodeMobile/State/MobileAppState+Inbound.swift @@ -128,7 +128,16 @@ extension MobileAppState { messagesBySession[active] = [] } loadingThreadMessageSessions.remove(active) - activeSessionID = active + // The snapshot's activeSessionID is metadata that labels the + // carried activeSessionMessages — it must not be treated as a + // navigation command. Only adopt it when our local selection + // is the optimistic draft id produced by startNewSession and + // we're waiting for the desktop to assign a real id; otherwise + // a reconnect-driven snapshot would yank the user out of the + // thread they're currently reading. + if let current = activeSessionID, MobileDraftSessionID.isDraft(current) { + activeSessionID = active + } } hasReceivedInitialSnapshot = true refreshWidgetData()