Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 51 additions & 35 deletions RxCode/App/AppState+CrossProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -259,17 +259,52 @@
}
}

// 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,
Expand All @@ -279,55 +314,35 @@
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
Expand All @@ -336,7 +351,7 @@
[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 `<clientId>::<model>` key (from the picker)
Expand Down Expand Up @@ -884,6 +899,7 @@
"[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):
Expand Down Expand Up @@ -976,4 +992,4 @@
}
}

}

Check warning on line 995 in RxCode/App/AppState+CrossProject.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 795 (file_length)
12 changes: 12 additions & 0 deletions RxCode/App/AppState+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
let deduplicated = projects.filter { seenPaths.insert($0.path).inserted }
Expand Down
1 change: 1 addition & 0 deletions RxCode/App/AppState+Stream.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions RxCode/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@
/// `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] = [:]
Expand Down Expand Up @@ -1048,4 +1055,4 @@
return message
}
}
}

Check warning on line 1058 in RxCode/App/AppState.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 675 (file_length)
6 changes: 6 additions & 0 deletions RxCode/Services/ACPService+Spawn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 7 additions & 0 deletions RxCode/Services/ClaudeService+Discovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions RxCode/Services/CodexAppServer+Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions RxCode/Services/IDEServer/AppState+IDEToolHandling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).")
}

Expand Down
27 changes: 5 additions & 22 deletions RxCode/Services/PermissionServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,7 +54,6 @@ actor PermissionServer {
var reasonOverride: String?
}
private var pending: [String: Pending] = [:]
private var timeoutTasks: [String: Task<Void, Never>] = [:]

/// In-memory only; cleared on `stop()`.
private var sessionToolAllows: Set<String> = []
Expand Down Expand Up @@ -163,8 +161,6 @@ actor PermissionServer {
}
}
pending.removeAll()
timeoutTasks.values.forEach { $0.cancel() }
timeoutTasks.removeAll()
for continuation in subscribers.values {
continuation.finish()
}
Expand All @@ -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
Expand Down Expand Up @@ -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.
Comment on lines +437 to +439
private func waitForDecision(
toolUseId: String,
sessionId: String?,
Expand Down Expand Up @@ -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
}

Expand All @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions RxCode/Views/Toolbar/TodoProgressToolbarItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion RxCodeMobile/State/MobileAppState+Inbound.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading