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
8 changes: 8 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ public protocol HookController: AnyObject {

/// Whether the thread should skip all lifecycle hooks (e.g. a review thread).
func threadSkipsHooks(sessionId: String) -> Bool
/// Whether session-end lifecycle hooks must be skipped for the current turn
/// because it is a *planning* turn — the session is in plan mode, or the
/// agent emitted an `ExitPlanMode` plan the user hasn't decided yet. Code
/// review, commit/push, and every other stop hook must not run on a plan
/// (reviewing/committing an unaccepted plan is always wrong). Enforced
/// centrally in `HookManager` ahead of both the completion and cancellation
/// session-end dispatch paths.
func sessionEndHooksSuppressed(sessionKey: String, sessionId: String) -> Bool
/// Resolve a stored hook model selection into the provider/model pair used
/// for a spawned agent thread. Empty selections inherit the reviewed thread.
func resolveAgentModelSelection(storedModel: String?, fallbackSessionId: String) -> (provider: AgentProvider, model: String)?
Expand Down
11 changes: 11 additions & 0 deletions Packages/Sources/RxCodeCore/Utilities/AppSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ public enum AppSupport {
return target
}()

public static let personalWorkspaceID = "personal"

public static func workspaceScopedURL(id: String) -> URL {
if id == personalWorkspaceID {
return bundleScopedURL
}
return bundleScopedURL
.appendingPathComponent("workspaces", isDirectory: true)
.appendingPathComponent(id, isDirectory: true)
}

private static var directoryName: String {
let bundleID = Bundle.main.bundleIdentifier ?? "com.idealapp.RxCode"
if bundleID == "com.idealapp.RxCode" { return "RxCode" }
Expand Down
24 changes: 14 additions & 10 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
DF46683A2FCDB56B002D9562 /* JSONSchemaForm in Frameworks */ = {isa = PBXBuildFile; productRef = DF4668392FCDB56B002D9562 /* JSONSchemaForm */; };
DF46683C2FCDB56B002D9562 /* JSONSchemaValidator in Frameworks */ = {isa = PBXBuildFile; productRef = DF46683B2FCDB56B002D9562 /* JSONSchemaValidator */; };
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; };
FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */; };
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; };
DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; };
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */; };
Expand Down Expand Up @@ -154,6 +155,7 @@
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = "<group>"; };
DF5B0DDE2FCB300100CE36F /* MobileUnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = MobileUnitTestPlan.xctestplan; sourceTree = "<group>"; };
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = "<group>"; };
FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanModeHookSuppressionTests.swift; sourceTree = "<group>"; };
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = "<group>"; };
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = "<group>"; };
E62000002FCB000100000001 /* MemoryIntentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryIntentTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -316,11 +318,11 @@
DF46526A2FCD34B3002D9562 /* JSONSchemaValidator in Frameworks */,
DFAA00012FCD34B3002D9562 /* JSONSchema in Frameworks */,
DF462A622FC6EDCE002D9562 /* RxAuthSwiftUI in Frameworks */,
DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */,
E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */,
E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */,
DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */,
E6D001072FA0000100000001 /* RxCodeSync in Frameworks */,
DF4652682FCD34B3002D9562 /* JSONSchemaForm in Frameworks */,
E6D001042FA0000100000001 /* RxCodeChatKit in Frameworks */,
E6D001052FA0000100000001 /* RxCodeEditor in Frameworks */,
DF462DCB2FC73FA8002D9562 /* RxAuthSwiftUI in Frameworks */,
E6D001072FA0000100000001 /* RxCodeSync in Frameworks */,
DF462A602FC6EDCE002D9562 /* RxAuthSwift in Frameworks */,
DF23FF1D2FBB42F7008929A6 /* WaterfallGrid in Frameworks */,
FB0000030000000000000001 /* FirebaseAnalytics in Frameworks */,
Expand All @@ -342,6 +344,7 @@
7A5C0001000000000000A001 /* MockAgentBackend.swift */,
7A5C0002000000000000A001 /* CrossProjectSendConcurrencyTests.swift */,
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */,
FE0A11BB22CC33DD44EE5502 /* PlanModeHookSuppressionTests.swift */,
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */,
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */,
5C2222222FCB200000000001 /* BriefingThreadRowTests.swift */,
Expand Down Expand Up @@ -618,11 +621,11 @@
packageProductDependencies = (
E6A001012F8A000100000001 /* SwiftTerm */,
E6C001012F9B000100000001 /* Sparkle */,
E6D001012FA0000100000001 /* RxCodeCore */,
E6D001022FA0000100000001 /* RxCodeChatKit */,
E6D001082FA0000100000001 /* RxCodeEditor */,
E6D001062FA0000100000001 /* RxCodeSync */,
DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */,
E6D001012FA0000100000001 /* RxCodeCore */,
E6D001022FA0000100000001 /* RxCodeChatKit */,
E6D001082FA0000100000001 /* RxCodeEditor */,
E6D001062FA0000100000001 /* RxCodeSync */,
DF23FF1C2FBB42F7008929A6 /* WaterfallGrid */,
FB0000010000000000000001 /* FirebaseAnalytics */,
FB0000020000000000000001 /* FirebaseCrashlytics */,
DF4628912FC611E6002D9562 /* RxAuthSwift */,
Expand Down Expand Up @@ -797,6 +800,7 @@
7A5C0001000000000000A002 /* MockAgentBackend.swift in Sources */,
7A5C0002000000000000A002 /* CrossProjectSendConcurrencyTests.swift in Sources */,
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */,
FE0A11BB22CC33DD44EE5501 /* PlanModeHookSuppressionTests.swift in Sources */,
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */,
DFA0CCE12FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift in Sources */,
5C2222222FCB200000000002 /* BriefingThreadRowTests.swift in Sources */,
Expand Down
38 changes: 38 additions & 0 deletions RxCode/App/AppCore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation
import RxCodeCore

/// Process-global state shared by every workspace.
///
/// `AppState` is per-workspace (one instance per open workspace window), but a
/// handful of services must be shared across all of them:
///
/// - `metaStore` / `cliStore` read & write **global** on-disk locations
/// (`~/.claude/projects`, `~/Library/Application Support/RxCode/session-meta`)
/// that are not workspace-scoped. Giving each workspace its own instance would
/// create incoherent in-memory caches over the same files.
/// - `claude` manages CLI subprocess lifecycle (PGIDs, stdin handles, descendant
/// trackers) keyed by stream id. A single shared instance keeps one registry of
/// running streams across all windows. Event routing stays correct because each
/// `AppState` owns its own `AsyncStream` iteration (pull model) — see
/// `AppState+CrossProject.processStream`.
/// - `workspaceRegistry` is the single source of truth for the workspace list and
/// the active-workspace selection, persisted to `workspaces.json`.
///
/// One `AppCore` is created by `RxCodeApp` and handed to every `AppState`.
@Observable
@MainActor
final class AppCore {
let metaStore: SessionMetaStore
let cliStore: CLISessionStore
let claude: ClaudeService
let workspaceRegistry: WorkspaceRegistry

init() {
let metaStore = SessionMetaStore()
let cliStore = CLISessionStore(metaStore: metaStore)
self.metaStore = metaStore
self.cliStore = cliStore
self.claude = ClaudeService(cliStore: cliStore)
self.workspaceRegistry = WorkspaceRegistry()
}
}
25 changes: 21 additions & 4 deletions RxCode/App/AppState+Agents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -419,9 +419,15 @@ extension AppState {
// MARK: - MCP Actions

func refreshMCPServers() async {
let revision = mcpStateRevision
let mcp = mcp
mcpIsLoading = true
mcpListError = nil
defer { mcpIsLoading = false }
defer {
if revision == mcpStateRevision {
mcpIsLoading = false
}
}
do {
// Pass the active project so Settings can show global defaults plus
// the effective per-project override state.
Expand All @@ -447,8 +453,10 @@ extension AppState {
merged.append(info)
}
}
guard revision == mcpStateRevision else { return }
mcpServers = merged
} catch {
guard revision == mcpStateRevision else { return }
mcpListError = error.localizedDescription
logger.error("MCP list failed: \(error.localizedDescription, privacy: .public)")
}
Expand All @@ -457,7 +465,9 @@ extension AppState {
func probeMCPServer(name: String) async {
guard let info = mcpServers.first(where: { $0.name == name }) else {
// Fall back to a name-only probe if the row hasn't loaded yet.
await probeMCPServer(id: name, name: name, lookup: { await self.mcp.probe(name: name, projectPath: self.activeProjectPath) })
let mcp = mcp
let activeProjectPath = activeProjectPath
await probeMCPServer(id: name, name: name, lookup: { await mcp.probe(name: name, projectPath: activeProjectPath) })
return
}
await probeMCPServer(info: info)
Expand All @@ -467,16 +477,23 @@ extension AppState {
/// multiple scopes/projects (aggregated Settings list) so the right
/// configuration is resolved.
func probeMCPServer(info: MCPServerInfo) async {
await probeMCPServer(id: info.id, name: info.name, lookup: { await self.mcp.probe(info: info) })
let mcp = mcp
await probeMCPServer(id: info.id, name: info.name, lookup: { await mcp.probe(info: info) })
}

func probeMCPServer(id: String, name: String, lookup: @escaping () async -> MCPProbeResult) async {
let revision = mcpStateRevision
guard !mcpInFlightProbes.contains(id) else { return }
mcpInFlightProbes.insert(id)
defer { mcpInFlightProbes.remove(id) }
defer {
if revision == mcpStateRevision {
mcpInFlightProbes.remove(id)
}
}

let previousStatus: MCPStatus? = mcpServers.first(where: { $0.id == id })?.status
let result = await lookup()
guard revision == mcpStateRevision else { return }
mcpProbeResults[id] = result

let newStatus: MCPStatus = result.ok
Expand Down
12 changes: 10 additions & 2 deletions RxCode/App/AppState+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ extension AppState {
// MARK: - Marketplace

func loadMarketplace(forceRefresh: Bool = false) async {
let revision = marketplaceStateRevision
let marketplace = marketplace
marketplaceLoading = true
marketplaceSourceError = nil
defer { marketplaceLoading = false }
defer {
if revision == marketplaceStateRevision {
marketplaceLoading = false
}
}

async let catalog = marketplace.fetchCatalog(forceRefresh: forceRefresh)
async let installed = marketplace.installedPluginNames()
Expand All @@ -20,9 +26,11 @@ extension AppState {
let installedNames = await installed
await marketplace.importInstalledPlugins(catalog: fetchedCatalog, installedNames: installedNames)

let customSources = await marketplace.customSources()
guard revision == marketplaceStateRevision else { return }
marketplaceCatalog = fetchedCatalog
marketplaceInstalledNames = installedNames
marketplaceCustomSources = await marketplace.customSources()
marketplaceCustomSources = customSources
}

func installMarketplacePlugin(_ plugin: MarketplacePlugin) async {
Expand Down
18 changes: 2 additions & 16 deletions RxCode/App/AppState+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,6 @@ extension AppState {
ThemeStore.shared.fontSizeAdjustment = fontSizeAdjustment
ThemeStore.shared.messageFontSizeAdjustment = messageFontSizeAdjustment

// Supply MobileSyncService with desktop-side context for the mobile
// job Live Activity and home-screen widget pushes.
MobileSyncService.shared.projectNameResolver = { [weak self] id in
self?.projects.first { $0.id == id }?.name
}
MobileSyncService.shared.usageSnapshotProvider = { [weak self] in
(
cc: self?.latestRateLimitUsage?.fiveHourPercent,
ccWeekly: self?.latestRateLimitUsage?.sevenDayPercent,
codex: self?.latestCodexRateLimitUsage?.fiveHourPercent,
codexWeekly: self?.latestCodexRateLimitUsage?.sevenDayPercent
)
}

await refreshAgentInstallations()

// Prewarm each backend's shell PATH cache in parallel so the first
Expand Down Expand Up @@ -247,7 +233,7 @@ extension AppState {
await loadACPClientsFromDisk()
Task { await self.refreshACPRegistry(forceRefresh: false) }

permissionMode = Self.readPermissionModeFromSettings()
permissionMode = PermissionMode(rawValue: workspaceDefaults.string(for: "selectedPermissionMode") ?? "") ?? .default

do {
try await permission.start()
Expand Down Expand Up @@ -430,7 +416,7 @@ extension AppState {
let project = projects.first(where: { $0.id == projectId })
{
selectProject(project, in: window)
} else if let savedId = UserDefaults.standard.string(forKey: "selectedProjectId"),
} else if let savedId = workspaceDefaults.string(for: "selectedProjectId"),
let uuid = UUID(uuidString: savedId),
let project = projects.first(where: { $0.id == uuid })
{
Expand Down
31 changes: 31 additions & 0 deletions RxCode/App/AppState+MobileSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@
// MARK: - Mobile Sync Bridge

extension AppState {
/// Make this workspace the single owner of mobile sync: register the inbound
/// request observers and supply the desktop-side resolvers. Called by
/// `WorkspaceManager` when this workspace becomes frontmost. Idempotent.
func bindMobileSyncOwnership() {
guard mobileSyncObservers.isEmpty else { return }
setupMobileSyncBridge()

// Supply MobileSyncService with desktop-side context for the mobile
// job Live Activity and home-screen widget pushes.
MobileSyncService.shared.projectNameResolver = { [weak self] id in
self?.projects.first { $0.id == id }?.name
}
MobileSyncService.shared.usageSnapshotProvider = { [weak self] in
(
cc: self?.latestRateLimitUsage?.fiveHourPercent,
ccWeekly: self?.latestRateLimitUsage?.sevenDayPercent,
codex: self?.latestCodexRateLimitUsage?.fiveHourPercent,
codexWeekly: self?.latestCodexRateLimitUsage?.sevenDayPercent
)
}
}

/// Relinquish mobile-sync ownership: remove this workspace's inbound request
/// observers so it stops responding to mobile requests once another
/// workspace becomes frontmost.
func unbindMobileSyncOwnership() {
let center = NotificationCenter.default
mobileSyncObservers.forEach { center.removeObserver($0) }
mobileSyncObservers.removeAll()
}

func setupMobileSyncBridge() {
let center = NotificationCenter.default
let snapshotObserver = center.addObserver(
Expand Down Expand Up @@ -774,4 +805,4 @@
await MobileSyncService.shared.send(.deleteProjectResult(result), toHex: hex)
}

}

Check warning on line 808 in RxCode/App/AppState+MobileSync.swift

View workflow job for this annotation

GitHub Actions / swiftlint

File should contain 600 lines or less excluding comments and whitespaces: currently contains 709 (file_length)
4 changes: 2 additions & 2 deletions RxCode/App/AppState+Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ extension AppState {
static let availableModels = ["default", "best", "opus", "opus[1m]", "opusplan", "sonnet", "sonnet[1m]", "haiku"]
static let fallbackCodexModels = ["gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex"]
nonisolated static let defaultOpenAISummarizationEndpoint = "https://api.openai.com/v1"
static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization"
static let openAISummarizationKeychainAccount = "apiKey"
nonisolated static let openAISummarizationKeychainService = "com.idealapp.RxCode.openai-summarization"
nonisolated static let openAISummarizationKeychainAccount = "apiKey"

static var availableClaudeModels: [AgentModel] {
availableModels.map {
Expand Down
44 changes: 44 additions & 0 deletions RxCode/App/AppState+PlanModeHooks.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation
import RxCodeChatKit
import RxCodeCore

// MARK: - Plan-mode session-end hook suppression

extension AppState {
/// Whether session-end lifecycle hooks (code review, commit/push, send
/// message, and user stop hooks) must be skipped for the current turn because
/// it is a *planning* turn: the session is in plan mode, or the agent emitted
/// an `ExitPlanMode` plan that the user has not decided yet. Acting on a plan
/// (reviewing or committing it) before the user accepts it is always wrong, so
/// this is enforced centrally in `HookManager` ahead of both the completion
/// and cancellation dispatch paths.
///
/// The CLI rotates a session's id mid-life, so the stop dispatch may key the
/// state by either the resolved `sessionId` or the in-memory `sessionKey` —
/// both are tried.
func sessionEndHooksSuppressed(sessionKey: String, sessionId: String) -> Bool {
guard let state = sessionStates[sessionId] ?? sessionStates[sessionKey] else { return false }
if state.planMode { return true }
return Self.hasUndecidedExitPlan(in: state)
}

/// True when the latest assistant run contains an `ExitPlanMode` tool call the
/// user hasn't accepted or rejected yet (the plan is "ready" and awaiting a
/// decision). Scans back only to the previous user turn so a plan decided in
/// an earlier turn doesn't keep stop hooks suppressed indefinitely. A decision
/// is recognized either from the tool result (the CLI's user-decision
/// follow-up) or the `planDecisionSummaries` sidecar, which survives CLI
/// session reloads that would overwrite the result.
static func hasUndecidedExitPlan(in state: SessionStreamState) -> Bool {
for message in state.messages.reversed() {
if message.role == .user { break }
for block in message.blocks {
guard let toolCall = block.toolCall, PlanLogic.isExitPlanMode(toolCall) else { continue }
let decidedViaResult = PlanLogic.isPlanDecided(toolCall)
let decidedViaSidecar = state.planDecisionSummaries[toolCall.id] != nil
if !decidedViaResult, !decidedViaSidecar { return true }
}
}
return false
}
}
Loading
Loading