From ebb401e713a805c1759347ef48b653516389e293 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:47:00 -0700 Subject: [PATCH 01/16] feat: support multiple Codex homes in Vault --- Resources/Localizable.xcstrings | 68 +++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 1 + ...rdShortcutSettingsFileStore+Template.swift | 10 ++ .../KeyboardShortcutSettingsFileStore.swift | 72 +++++++++- Sources/SessionIndexModels.swift | 95 ++++++++++++- Sources/SessionIndexStore+CodexSQL.swift | 52 +++++-- Sources/SessionIndexStore.swift | 131 ++++++++++++++++-- Sources/SessionIndexView.swift | 27 +++- cmuxTests/SessionIndexViewTests.swift | 43 +++++- docs/agent-hooks.md | 17 +++ web/data/cmux.schema.json | 40 ++++++ 11 files changed, 522 insertions(+), 34 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e9d4ef662519..fe755dd38e4a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -106095,6 +106095,74 @@ } } }, + "sessionIndex.codexHome.defaultLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "既定の Codex" + } + } + } + }, + "sessionIndex.codexHome.error.openDatabase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: cannot open state_5.sqlite (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: state_5.sqlite を開けません (%@)" + } + } + } + }, + "sessionIndex.codexHome.error.unsupportedSchema": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@: state_5.sqlite schema unsupported - %@. Falling back to file scan." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@: state_5.sqlite のスキーマに対応していません - %@。ファイルスキャンにフォールバックします。" + } + } + } + }, + "sessionIndex.codexHome.warning.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" は利用できないためスキップしました。" + } + } + } + }, "sessionIndex.agent.opencode": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 99dfa45c147c..e17b788d2e5f 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -39,6 +39,7 @@ extension CmuxSettingsFileStore { "app.commandPaletteSearchesAllSurfaces", "terminal.showScrollBar", "terminal.autoResumeAgentSessions", + "codex.additionalHomes", "notifications.dockBadge", "notifications.showInMenuBar", "notifications.unreadPaneRing", diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index ddb8c435a5d0..dfb9919ea5bc 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -82,6 +82,16 @@ extension CmuxSettingsFileStore { "autoResumeAgentSessions": AgentSessionAutoResumeSettings.defaultAutoResumeAgentSessions, ], ], + [ + "codex": [ + "additionalHomes": [ + [ + "path": "~/.codex-testA", + "displayName": "codex-testA", + ], + ], + ], + ], [ "notifications": [ "dockBadge": NotificationBadgeSettings.defaultDockBadgeEnabled, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 3d915014bf7a..09dec8332241 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -311,6 +311,9 @@ final class CmuxSettingsFileStore { if let terminalSection = root["terminal"] as? [String: Any] { parseTerminalSection(terminalSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let codexSection = root["codex"] as? [String: Any] { + parseCodexSection(codexSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let notificationsSection = root["notifications"] as? [String: Any] { parseNotificationsSection(notificationsSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -469,6 +472,20 @@ final class CmuxSettingsFileStore { } } + private func parseCodexSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard section.keys.contains("additionalHomes") else { return } + guard let homes = parseCodexAdditionalHomes(section["additionalHomes"], sourcePath: sourcePath) else { + return + } + snapshot.managedUserDefaults[CodexSessionHomeSettings.additionalHomesKey] = .string( + CodexSessionHomeSettings.encodedAdditionalHomes(homes) + ) + } + private func parseSidebarSection( _ section: [String: Any], sourcePath: String, @@ -1201,6 +1218,8 @@ final class CmuxSettingsFileStore { var sideEffects = ManagedDefaultBatchSideEffects() sideEffects.agentSessionAutoResumeDidChange = defaultsKey == AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey + sideEffects.codexSessionHomesDidChange = + defaultsKey == CodexSessionHomeSettings.additionalHomesKey let language = defaultsKey == LanguageSettings.languageKey ? AppLanguage(rawValue: UserDefaults.standard.string(forKey: defaultsKey) ?? "") ?? .system : nil let shouldApplyAppearance = defaultsKey == AppearanceSettings.appearanceModeKey let appearanceRawValue = shouldApplyAppearance ? UserDefaults.standard.string(forKey: defaultsKey) : nil @@ -1228,10 +1247,15 @@ final class CmuxSettingsFileStore { } private func applyManagedDefaultBatchSideEffects(_ sideEffects: ManagedDefaultBatchSideEffects) { - guard sideEffects.agentSessionAutoResumeDidChange else { return } + guard sideEffects.hasChanges else { return } let notificationCenter = notificationCenter let apply = { - AgentSessionAutoResumeSettings.notifyDidChange(notificationCenter: notificationCenter) + if sideEffects.agentSessionAutoResumeDidChange { + AgentSessionAutoResumeSettings.notifyDidChange(notificationCenter: notificationCenter) + } + if sideEffects.codexSessionHomesDidChange { + CodexSessionHomeSettings.notifyDidChange(notificationCenter: notificationCenter) + } } if Thread.isMainThread { apply() @@ -1328,6 +1352,43 @@ final class CmuxSettingsFileStore { return strings } + private func parseCodexAdditionalHomes( + _ rawValue: Any?, + sourcePath: String + ) -> [CodexSessionHomeSetting]? { + guard let values = rawValue as? [Any] else { + logInvalid("codex.additionalHomes", sourcePath: sourcePath) + return nil + } + + var homes: [CodexSessionHomeSetting] = [] + var seen: Set = [] + for (index, value) in values.enumerated() { + let parsed: CodexSessionHomeSetting? + if let path = jsonString(value) { + parsed = CodexSessionHomeSetting(path: path) + } else if let object = value as? [String: Any], + let path = jsonString(object["path"]) { + let displayName = jsonString(object["displayName"]) ?? jsonString(object["name"]) + parsed = CodexSessionHomeSetting(path: path, displayName: displayName) + } else { + logInvalid("codex.additionalHomes[\(index)]", sourcePath: sourcePath) + continue + } + + guard let parsed else { continue } + let trimmedPath = parsed.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPath.isEmpty else { + logInvalid("codex.additionalHomes[\(index)].path", sourcePath: sourcePath) + continue + } + let normalized = ((trimmedPath as NSString).expandingTildeInPath as NSString).standardizingPath + guard seen.insert(normalized).inserted else { continue } + homes.append(parsed) + } + return homes + } + } typealias KeyboardShortcutSettingsFileStore = CmuxSettingsFileStore @@ -1356,10 +1417,17 @@ private struct ResolvedSettingsSnapshot { private struct ManagedDefaultBatchSideEffects { var agentSessionAutoResumeDidChange = false + var codexSessionHomesDidChange = false + + var hasChanges: Bool { + agentSessionAutoResumeDidChange || codexSessionHomesDidChange + } mutating func merge(_ other: ManagedDefaultBatchSideEffects) { agentSessionAutoResumeDidChange = agentSessionAutoResumeDidChange || other.agentSessionAutoResumeDidChange + codexSessionHomesDidChange = + codexSessionHomesDidChange || other.codexSessionHomesDidChange } } diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index 96cb65a53df3..ddc88f814b67 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -179,6 +179,85 @@ enum OpenCodeDatabaseSnapshot { } } +// MARK: - Codex homes + +struct CodexSessionHomeSetting: Codable, Equatable, Sendable { + let path: String + let displayName: String? + + init(path: String, displayName: String? = nil) { + self.path = path + self.displayName = Self.normalizedOptional(displayName) + } + + private static func normalizedOptional(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} + +enum CodexSessionHomeSettings { + static let additionalHomesKey = "codexSessionAdditionalHomes" + static let didChangeNotification = Notification.Name("CodexSessionHomeSettings.didChange") + + static func additionalHomes(defaults: UserDefaults = .standard) -> [CodexSessionHomeSetting] { + guard let raw = defaults.string(forKey: additionalHomesKey), + let data = raw.data(using: .utf8), + let decoded = try? JSONDecoder().decode([CodexSessionHomeSetting].self, from: data) else { + return [] + } + return decoded + } + + static func encodedAdditionalHomes(_ homes: [CodexSessionHomeSetting]) -> String { + guard let data = try? JSONEncoder().encode(homes), + let raw = String(data: data, encoding: .utf8) else { + return "[]" + } + return raw + } + + static func notifyDidChange(notificationCenter: NotificationCenter = .default) { + notificationCenter.post(name: didChangeNotification, object: nil) + } +} + +struct CodexSessionHome: Hashable, Sendable { + let path: String + let label: String + let isDefault: Bool + + var stateDatabasePath: String { + (path as NSString).appendingPathComponent("state_5.sqlite") + } + + var sessionsRoot: String { + (path as NSString).appendingPathComponent("sessions") + } + + var resumeCodexHome: String? { + isDefault ? nil : path + } + + static func defaultHome() -> CodexSessionHome { + let path = ("~/.codex" as NSString).expandingTildeInPath + return CodexSessionHome( + path: (path as NSString).standardizingPath, + label: String(localized: "sessionIndex.codexHome.defaultLabel", defaultValue: "Default Codex"), + isDefault: true + ) + } + + static func additionalHome(from setting: CodexSessionHomeSetting) -> CodexSessionHome? { + let trimmed = setting.path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let expanded = (trimmed as NSString).expandingTildeInPath + let standardized = (expanded as NSString).standardizingPath + let label = setting.displayName ?? (standardized as NSString).abbreviatingWithTildeInPath + return CodexSessionHome(path: standardized, label: label, isDefault: false) + } +} + // MARK: - Session entry struct PullRequestLink: Hashable { @@ -190,7 +269,7 @@ struct PullRequestLink: Hashable { /// Agent-specific fields used to build the resume command with appropriate flags. enum AgentSpecifics: Hashable { case claude(model: String?, permissionMode: String?) - case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?) + case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?, codexHome: String?) case opencode(providerModel: String?, agentName: String?) case rovodev case hermesAgent(source: String?, model: String?, hermesHome: String?) @@ -209,6 +288,7 @@ struct SessionEntry: Identifiable, Hashable { let modified: Date let fileURL: URL? let specifics: AgentSpecifics + var sourceLabel: String? = nil var resumeWorkingDirectory: String? { guard let cwd, !cwd.isEmpty else { return nil } @@ -243,7 +323,7 @@ struct SessionEntry: Identifiable, Hashable { ["CLAUDE_CONFIG_DIR": $0, "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV": "1", "CMUX_PRESERVE_CLAUDE_AUTH_SELECTION_ENV_KEYS": "CLAUDE_CONFIG_DIR"] } ?? [:] return Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) - case let .codex(model, approval, sandbox, effort): + case let .codex(model, approval, sandbox, effort, codexHome): var parts = ["codex resume \(sessionId)"] if let model, !model.isEmpty { parts.append("-m \(Self.shellQuote(model))") @@ -257,7 +337,8 @@ struct SessionEntry: Identifiable, Hashable { if let effort, !effort.isEmpty { parts.append("-c model_reasoning_effort=\(Self.shellQuote(effort))") } - return parts.joined(separator: " ") + let environment = codexHome.map { ["CODEX_HOME": $0] } ?? [:] + return Self.withShellEnvironment(environment, command: parts.joined(separator: " ")) case let .opencode(providerModel, agentName): var parts = ["opencode --session \(sessionId)"] if let providerModel, !providerModel.isEmpty { @@ -437,4 +518,12 @@ struct SessionEntry: Identifiable, Hashable { guard let cwd, !cwd.isEmpty else { return nil } return (cwd as NSString).lastPathComponent } + + var sourceAndCwdLabel: String? { + let parts = [sourceLabel, cwdLabel].compactMap { value -> String? in + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } + return parts.isEmpty ? nil : parts.joined(separator: " - ") + } } diff --git a/Sources/SessionIndexStore+CodexSQL.swift b/Sources/SessionIndexStore+CodexSQL.swift index b60c5cf3e270..985c2928342b 100644 --- a/Sources/SessionIndexStore+CodexSQL.swift +++ b/Sources/SessionIndexStore+CodexSQL.swift @@ -27,9 +27,13 @@ extension SessionIndexStore { nonisolated static func loadCodexEntriesViaSQL( needle: String, cwdFilter: String?, offset: Int, limit: Int, errorBag: ErrorBag, - dbPath: String = ("~/.codex/state_5.sqlite" as NSString).expandingTildeInPath, - sessionsRoot: String = defaultCodexSessionsRoot() + dbPath: String? = nil, + sessionsRoot: String? = nil, + home: CodexSessionHome = .defaultHome(), + sourceLabel: String? = nil ) async -> [SessionEntry]? { + let dbPath = dbPath ?? home.stateDatabasePath + let sessionsRoot = sessionsRoot ?? home.sessionsRoot let fm = FileManager.default guard fm.fileExists(atPath: dbPath) else { return nil } @@ -48,7 +52,11 @@ extension SessionIndexStore { var db: OpaquePointer? guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { - errorBag.add("Codex: cannot open state_5.sqlite (\(sqliteMessage(db) ?? "unknown error"))") + let format = String( + localized: "sessionIndex.codexHome.error.openDatabase", + defaultValue: "%@: cannot open state_5.sqlite (%@)" + ) + errorBag.add(String(format: format, home.label, sqliteMessage(db) ?? "unknown error")) sqlite3_close(db) return nil } @@ -76,7 +84,11 @@ extension SessionIndexStore { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { - errorBag.add("Codex: schema unsupported — \(sqliteMessage(db) ?? "prepare failed"). Falling back to file scan.") + let format = String( + localized: "sessionIndex.codexHome.error.unsupportedSchema", + defaultValue: "%@: state_5.sqlite schema unsupported - %@. Falling back to file scan." + ) + errorBag.add(String(format: format, home.label, sqliteMessage(db) ?? "prepare failed")) sqlite3_finalize(stmt) return nil } @@ -104,7 +116,7 @@ extension SessionIndexStore { )) } guard !needle.isEmpty else { - return records.map(codexEntry(from:)) + return records.map { codexEntry(from: $0, home: home, sourceLabel: sourceLabel) } } guard limit > 0 else { return [] } @@ -124,7 +136,7 @@ extension SessionIndexStore { ) guard matches else { continue } if matchedCount >= offset { - entries.append(codexEntry(from: record)) + entries.append(codexEntry(from: record, home: home, sourceLabel: sourceLabel)) if entries.count >= limit { break } } matchedCount += 1 @@ -139,9 +151,17 @@ extension SessionIndexStore { cwdFilter: String? = nil, offset: Int = 0, limit: Int = 100, - sessionsRoot: String = defaultCodexSessionsRoot() + sessionsRoot: String = defaultCodexSessionsRoot(), + codexHome: String? = nil, + sourceLabel: String? = nil ) async -> SearchOutcome { let bag = ErrorBag() + let homePath = codexHome ?? URL(fileURLWithPath: stateDBPath).deletingLastPathComponent().path + let home = CodexSessionHome( + path: (homePath as NSString).standardizingPath, + label: sourceLabel ?? String(localized: "sessionIndex.codexHome.defaultLabel", defaultValue: "Default Codex"), + isDefault: codexHome == nil + ) let entries = await loadCodexEntriesViaSQL( needle: needle.lowercased(), cwdFilter: cwdFilter, @@ -149,13 +169,19 @@ extension SessionIndexStore { limit: limit, errorBag: bag, dbPath: stateDBPath, - sessionsRoot: sessionsRoot + sessionsRoot: sessionsRoot, + home: home, + sourceLabel: sourceLabel ) ?? [] return SearchOutcome(entries: entries, errors: bag.snapshot()) } #endif - nonisolated private static func codexEntry(from record: CodexThreadRecord) -> SessionEntry { + nonisolated private static func codexEntry( + from record: CodexThreadRecord, + home: CodexSessionHome, + sourceLabel: String? + ) -> SessionEntry { let sandboxMode = record.sandboxJSON .flatMap { $0.data(using: .utf8) } .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } @@ -172,7 +198,7 @@ extension SessionIndexStore { let fileURL = record.normalizedRolloutPath.map { URL(fileURLWithPath: $0) } return SessionEntry( - id: "codex:" + (fileURL?.path ?? record.sessionId), + id: "codex:" + (fileURL?.path ?? "\(home.path):\(record.sessionId)"), agent: .codex, sessionId: record.sessionId, title: displayTitle, @@ -185,8 +211,10 @@ extension SessionIndexStore { model: record.model?.isEmpty == false ? record.model : nil, approvalPolicy: record.approvalMode?.isEmpty == false ? record.approvalMode : nil, sandboxMode: sandboxMode, - effort: record.reasoningEffort?.isEmpty == false ? record.reasoningEffort : nil - ) + effort: record.reasoningEffort?.isEmpty == false ? record.reasoningEffort : nil, + codexHome: home.resumeCodexHome + ), + sourceLabel: sourceLabel ) } diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index ddd64b168c2f..4437a2d50b1e 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -199,12 +199,19 @@ final class SessionIndexStore: ObservableObject { private var sectionsCacheRevision: UInt64 = 0 private var cachedSectionsRevision: UInt64? private var cachedSections: [IndexSection] = [] + private var codexSessionHomesCancellable: AnyCancellable? init() { self.agentOrder = Self.loadAgentOrder() self.directoryOrder = Self.loadDirectoryOrder() let storedGrouping = UserDefaults.standard.string(forKey: Self.groupingKey) self.grouping = SessionGrouping(rawValue: storedGrouping ?? "") ?? .directory + codexSessionHomesCancellable = NotificationCenter.default + .publisher(for: CodexSessionHomeSettings.didChangeNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reload() + } } /// Returns the sections for the current grouping mode, in the user-saved order. @@ -1446,26 +1453,105 @@ final class SessionIndexStore: ObservableObject { return Array(matched.prefix(target).dropFirst(offset).prefix(limit)) } - /// Returns Codex session entries paginated by mtime desc. - /// Primary path: query Codex's own `~/.codex/state_5.sqlite` (`threads` - /// table) — Codex pre-extracts cwd, title, model, branch, approval, sandbox, - /// effort, and rollout_path so we don't need to read jsonl files at all. - /// Fallback (DB missing): the file-scan path below. + /// Returns Codex session entries paginated by mtime desc across the default + /// CODEX_HOME plus any additional homes configured in cmux.json. nonisolated private static func loadCodexEntries( needle: String, cwdFilter: String?, offset: Int, limit: Int, errorBag: ErrorBag + ) async -> [SessionEntry] { + let homes = codexSessionHomes(errorBag: errorBag) + guard !homes.isEmpty, limit > 0 else { return [] } + + let target = offset + limit + let showSourceLabels = homes.count > 1 + let merged = await withTaskGroup(of: [SessionEntry].self) { group in + for home in homes { + let sourceLabel = showSourceLabels || !home.isDefault ? home.label : nil + group.addTask { + await loadCodexEntries( + home: home, + sourceLabel: sourceLabel, + needle: needle, + cwdFilter: cwdFilter, + offset: 0, + limit: target, + errorBag: errorBag + ) + } + } + var merged: [SessionEntry] = [] + for await entries in group { + merged.append(contentsOf: entries) + } + return merged + } + return Array(merged.sorted { $0.modified > $1.modified }.dropFirst(offset).prefix(limit)) + } + + /// Primary path: query Codex's own `state_5.sqlite` (`threads` table) for a + /// single CODEX_HOME. Fallback (DB missing or unsupported): file scan below. + nonisolated private static func loadCodexEntries( + home: CodexSessionHome, + sourceLabel: String?, + needle: String, + cwdFilter: String?, + offset: Int, + limit: Int, + errorBag: ErrorBag ) async -> [SessionEntry] { if let viaSQL = await loadCodexEntriesViaSQL( - needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit, - errorBag: errorBag + needle: needle, + cwdFilter: cwdFilter, + offset: offset, + limit: limit, + errorBag: errorBag, + home: home, + sourceLabel: sourceLabel ) { return viaSQL } return await loadCodexEntriesFromDisk( - needle: needle, cwdFilter: cwdFilter, offset: offset, limit: limit + home: home, + sourceLabel: sourceLabel, + needle: needle, + cwdFilter: cwdFilter, + offset: offset, + limit: limit, + errorBag: errorBag ) } + nonisolated private static func codexSessionHomes(errorBag: ErrorBag) -> [CodexSessionHome] { + let fm = FileManager.default + var homes: [CodexSessionHome] = [] + var seen: Set = [] + + func append(_ home: CodexSessionHome) { + guard seen.insert(home.path).inserted else { return } + var isDirectory: ObjCBool = false + let exists = fm.fileExists(atPath: home.path, isDirectory: &isDirectory) + guard exists, isDirectory.boolValue, fm.isReadableFile(atPath: home.path) else { + guard !home.isDefault else { return } + let format = String( + localized: "sessionIndex.codexHome.warning.unavailable", + defaultValue: "Codex home \"%@\" is unavailable and was skipped." + ) + let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) + errorBag.add(message) + NSLog("[SessionIndexStore] %@", message) + return + } + homes.append(home) + } + + append(CodexSessionHome.defaultHome()) + for setting in CodexSessionHomeSettings.additionalHomes() { + guard let home = CodexSessionHome.additionalHome(from: setting) else { continue } + append(home) + } + return homes + } + nonisolated static func fileContainsNeedle(url: URL, needle: String) -> Bool { guard !needle.isEmpty, let data = try? Data(contentsOf: url, options: .mappedIfSafe), @@ -1478,9 +1564,15 @@ final class SessionIndexStore: ObservableObject { /// Disk-scan fallback for Codex when state_5.sqlite isn't present (very old /// Codex installs, or non-default config). Same shape as the original loader. nonisolated private static func loadCodexEntriesFromDisk( - needle: String, cwdFilter: String?, offset: Int, limit: Int + home: CodexSessionHome, + sourceLabel: String?, + needle: String, + cwdFilter: String?, + offset: Int, + limit: Int, + errorBag: ErrorBag ) async -> [SessionEntry] { - let root = ("~/.codex/sessions" as NSString).expandingTildeInPath + let root = home.sessionsRoot let fm = FileManager.default var rgFiltered = false @@ -1499,7 +1591,18 @@ final class SessionIndexStore: ObservableObject { at: rootURL, includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], options: [.skipsHiddenFiles] - ) else { return [] } + ) else { + if !home.isDefault { + let format = String( + localized: "sessionIndex.codexHome.warning.unavailable", + defaultValue: "Codex home \"%@\" is unavailable and was skipped." + ) + let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) + errorBag.add(message) + NSLog("[SessionIndexStore] %@", message) + } + return [] + } for case let url as URL in enumerator { guard url.pathExtension == "jsonl" else { continue } let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) @@ -1546,8 +1649,10 @@ final class SessionIndexStore: ObservableObject { model: parsed.model, approvalPolicy: parsed.approvalPolicy, sandboxMode: parsed.sandboxMode, - effort: parsed.effort - ) + effort: parsed.effort, + codexHome: home.resumeCodexHome + ), + sourceLabel: sourceLabel )) } return Array(matches.dropFirst(offset).prefix(limit)) diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index 0b5ce3c2827a..7b35d5b282d0 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -520,6 +520,15 @@ private struct SessionRow: View, Equatable { .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) .truncationMode(.tail) + .layoutPriority(1) + if let sourceLabel = entry.sourceLabel { + Text(sourceLabel) + .font(.system(size: 11)) + .foregroundColor(.secondary.opacity(0.75)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 110, alignment: .trailing) + } Spacer(minLength: 8) Text(relativeTime(entry.modified)) .font(.system(size: 12).monospacedDigit()) @@ -588,6 +597,9 @@ private struct SessionRow: View, Equatable { private var helpText: String { var lines: [String] = [entry.displayTitle] + if let sourceLabel = entry.sourceLabel { + lines.append(sourceLabel) + } if let cwd = entry.cwdLabel { lines.append(cwd) } @@ -706,8 +718,8 @@ private struct SessionTranscriptPreviewView: View { .foregroundColor(.primary) .lineLimit(1) .truncationMode(.middle) - if let cwd = entry.cwdLabel { - Text(cwd) + if let detail = entry.sourceAndCwdLabel { + Text(detail) .font(.system(size: 11)) .foregroundColor(.secondary) .lineLimit(1) @@ -2221,6 +2233,15 @@ private struct PopoverRow: View, Equatable { .foregroundColor(.primary.opacity(0.92)) .lineLimit(1) .truncationMode(.tail) + .layoutPriority(1) + if let sourceLabel = entry.sourceLabel { + Text(sourceLabel) + .font(.system(size: 10)) + .foregroundColor(.secondary.opacity(0.75)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 100, alignment: .trailing) + } Spacer(minLength: 8) modifiedText } @@ -2234,7 +2255,7 @@ private struct PopoverRow: View, Equatable { .onDrag { sessionDragItemProvider(for: entry) } - .help(entry.cwdLabel ?? entry.displayTitle) + .help(entry.sourceAndCwdLabel ?? entry.displayTitle) .contextMenu { sessionRowMenuItems(entry: entry, onResume: { _ in onActivate() }) } diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index a1e5a517ff98..841a2b8b6962 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -133,6 +133,47 @@ final class SessionIndexViewTests: XCTestCase { XCTAssertEqual(outcome.entries.map(\.sessionId), ["codex-transcript-match"]) } + func testCodexSQLHomeLabelAndResumeCommandUseConfiguredHome() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-index-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let codexHome = tempDir.appendingPathComponent("codex home", isDirectory: true) + let sessionsRoot = codexHome.appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true) + + let rolloutURL = sessionsRoot.appendingPathComponent("rollout-custom-home.jsonl") + try """ + {"timestamp":"2026-05-01T09:00:00.000Z","type":"session_meta","payload":{"id":"custom-home-session","cwd":"/tmp/project"}} + """.write(to: rolloutURL, atomically: true, encoding: .utf8) + + let stateDB = codexHome.appendingPathComponent("state_5.sqlite") + try makeCodexStateDatabase( + at: stateDB, + rolloutURL: rolloutURL, + sessionId: "custom-home-session" + ) + + let outcome = await SessionIndexStore.loadCodexEntriesForTesting( + stateDBPath: stateDB.path, + needle: "", + offset: 0, + limit: 10, + sessionsRoot: sessionsRoot.path, + codexHome: codexHome.path, + sourceLabel: "Test A" + ) + + XCTAssertEqual(outcome.errors, []) + XCTAssertEqual(outcome.entries.count, 1) + XCTAssertEqual(outcome.entries.first?.sourceLabel, "Test A") + XCTAssertEqual( + outcome.entries.first?.resumeCommand, + "cd /tmp/project && env CODEX_HOME='\(codexHome.path)' codex resume custom-home-session -m gpt-5.5 -a never -s danger-full-access -c model_reasoning_effort=medium" + ) + } + func testSectionPopoverHostCoordinatorSkipsHiddenRefreshes() { let harness = makeHarness() let coordinator = harness.host.makeCoordinator() @@ -333,7 +374,7 @@ private extension SessionAgent { case .claude: return .claude(model: nil, permissionMode: nil) case .codex: - return .codex(model: nil, approvalPolicy: nil, sandboxMode: nil, effort: nil) + return .codex(model: nil, approvalPolicy: nil, sandboxMode: nil, effort: nil, codexHome: nil) case .opencode: return .opencode(providerModel: nil, agentName: nil) case .rovodev: diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 578d2ce84885..5535f7bf4a36 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -78,6 +78,23 @@ and browser state. Restored agent terminals stay idle until you resume them manu | Factory | none | `CMUX_FACTORY_HOOKS_DISABLED=1` | | Qoder | `QODER_CONFIG_DIR` | `CMUX_QODER_HOOKS_DISABLED=1` | +Vault always scans the default Codex home at `~/.codex`. To include additional +isolated Codex environments in the right sidebar, add them to `cmux.json`: + +```json +{ + "codex": { + "additionalHomes": [ + "~/.codex-testA", + { "path": "~/.codex-testB", "displayName": "codex-testB" } + ] + } +} +``` + +Sessions from configured homes show their source label in Vault, and resuming +one sets `CODEX_HOME` to the matching directory. + Pi uses Pi's extension system, not the legacy Pi hooks API. The installed extension is auto-discovered from `~/.pi/agent/extensions/` or `$PI_CODING_AGENT_DIR/extensions/`. ## Troubleshooting diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index c7bc26d32a02..f833b03c59a9 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -313,6 +313,46 @@ } } }, + "codex": { + "title": "codex", + "description": "Codex session discovery settings.", + "type": "object", + "additionalProperties": false, + "properties": { + "additionalHomes": { + "type": "array", + "default": [], + "description": "Additional CODEX_HOME directories to scan alongside ~/.codex for Vault session discovery.", + "items": { + "oneOf": [ + { + "type": "string", + "description": "Path to an additional CODEX_HOME directory, for example ~/.codex-testA." + }, + { + "type": "object", + "additionalProperties": false, + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Path to an additional CODEX_HOME directory." + }, + "displayName": { + "type": "string", + "description": "Optional label shown next to sessions from this CODEX_HOME." + }, + "name": { + "type": "string", + "description": "Alias for displayName." + } + } + } + ] + } + } + } + }, "notifications": { "title": "notifications", "description": "Notification behavior from Settings > Notifications.", From a3afee4f28d51f0dd76e783759c1acafb30a980f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 22:54:54 -0700 Subject: [PATCH 02/16] fix: use unified logging for Codex home warnings --- Sources/SessionIndexModels.swift | 28 +++++++++++++++++++++++++++- Sources/SessionIndexStore.swift | 7 +++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index ddc88f814b67..4c6c44691757 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -288,7 +288,33 @@ struct SessionEntry: Identifiable, Hashable { let modified: Date let fileURL: URL? let specifics: AgentSpecifics - var sourceLabel: String? = nil + let sourceLabel: String? + + init( + id: String, + agent: SessionAgent, + sessionId: String, + title: String, + cwd: String?, + gitBranch: String?, + pullRequest: PullRequestLink?, + modified: Date, + fileURL: URL?, + specifics: AgentSpecifics, + sourceLabel: String? = nil + ) { + self.id = id + self.agent = agent + self.sessionId = sessionId + self.title = title + self.cwd = cwd + self.gitBranch = gitBranch + self.pullRequest = pullRequest + self.modified = modified + self.fileURL = fileURL + self.specifics = specifics + self.sourceLabel = sourceLabel + } var resumeWorkingDirectory: String? { guard let cwd, !cwd.isEmpty else { return nil } diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 4437a2d50b1e..e03517601fb7 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -3,10 +3,13 @@ import Bonsplit import CMUXAgentLaunch import Combine import Foundation +import OSLog import SQLite3 // MARK: - Parsed metadata cache +nonisolated private let sessionIndexLogger = Logger(subsystem: "ai.manaflow.cmux", category: "SessionIndexStore") + /// Process-wide cache for parsed Claude session metadata, keyed by file URL with /// mtime as the freshness check. Avoids re-reading and re-parsing the same /// jsonls across pagination calls. Bounded by `maxEntries` to keep memory in @@ -1538,7 +1541,7 @@ final class SessionIndexStore: ObservableObject { ) let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) errorBag.add(message) - NSLog("[SessionIndexStore] %@", message) + sessionIndexLogger.warning("Codex home unavailable and skipped: \(home.path, privacy: .private)") return } homes.append(home) @@ -1599,7 +1602,7 @@ final class SessionIndexStore: ObservableObject { ) let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) errorBag.add(message) - NSLog("[SessionIndexStore] %@", message) + sessionIndexLogger.warning("Codex sessions root unavailable and skipped: \(root, privacy: .private)") } return [] } From 50526af40bdc2c458fd71055910a6335c0b811ed Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:01:33 -0700 Subject: [PATCH 03/16] test: cover empty configured Codex homes --- Sources/SessionIndexStore.swift | 30 +++++++++++++++++++++++++++ cmuxTests/SessionIndexViewTests.swift | 18 ++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index e03517601fb7..4442ab41ffec 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1661,6 +1661,36 @@ final class SessionIndexStore: ObservableObject { return Array(matches.dropFirst(offset).prefix(limit)) } + #if DEBUG + nonisolated static func loadCodexEntriesFromDiskForTesting( + codexHome: String, + sourceLabel: String? = nil, + isDefault: Bool = false, + needle: String = "", + cwdFilter: String? = nil, + offset: Int = 0, + limit: Int = 100 + ) async -> SearchOutcome { + let bag = ErrorBag() + let standardizedHome = (codexHome as NSString).standardizingPath + let home = CodexSessionHome( + path: standardizedHome, + label: sourceLabel ?? (standardizedHome as NSString).abbreviatingWithTildeInPath, + isDefault: isDefault + ) + let entries = await loadCodexEntriesFromDisk( + home: home, + sourceLabel: sourceLabel, + needle: needle, + cwdFilter: cwdFilter, + offset: offset, + limit: limit, + errorBag: bag + ) + return SearchOutcome(entries: entries, errors: bag.snapshot()) + } + #endif + /// Returns OpenCode session entries paginated by `time_updated` desc. /// Empty needle skips the `LIKE` clause entirely so it's just `ORDER BY … LIMIT/OFFSET`. /// Sync because the SQL pass is fast and SQLite's API is sync; the caller diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index 841a2b8b6962..4aca00a8df20 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -174,6 +174,24 @@ final class SessionIndexViewTests: XCTestCase { ) } + func testCodexEmptyConfiguredHomeWithoutSessionsDirectoryIsEmptyWithoutWarning() async throws { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-index-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let codexHome = tempDir.appendingPathComponent("codex-empty-home", isDirectory: true) + try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true) + + let outcome = await SessionIndexStore.loadCodexEntriesFromDiskForTesting( + codexHome: codexHome.path, + sourceLabel: "Empty Home" + ) + + XCTAssertEqual(outcome.entries, []) + XCTAssertEqual(outcome.errors, []) + } + func testSectionPopoverHostCoordinatorSkipsHiddenRefreshes() { let harness = makeHarness() let coordinator = harness.host.makeCoordinator() From e5cc452e528a012dd434d6eae2660d02375c2002 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:02:29 -0700 Subject: [PATCH 04/16] fix: treat empty configured Codex homes as empty --- Sources/SessionIndexStore.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 4442ab41ffec..2b70ea32f567 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1595,7 +1595,8 @@ final class SessionIndexStore: ObservableObject { includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], options: [.skipsHiddenFiles] ) else { - if !home.isDefault { + let exists = fm.fileExists(atPath: root) + if !home.isDefault, exists { let format = String( localized: "sessionIndex.codexHome.warning.unavailable", defaultValue: "Codex home \"%@\" is unavailable and was skipped." From 1f76640e59f5fff5fb84a7c443b33ebdb08635c9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 13:41:01 -0700 Subject: [PATCH 05/16] test: normalize Codex home resume expectation --- cmuxTests/SessionIndexViewTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmuxTests/SessionIndexViewTests.swift b/cmuxTests/SessionIndexViewTests.swift index 4aca00a8df20..dbf5a6d4860f 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -168,9 +168,10 @@ final class SessionIndexViewTests: XCTestCase { XCTAssertEqual(outcome.errors, []) XCTAssertEqual(outcome.entries.count, 1) XCTAssertEqual(outcome.entries.first?.sourceLabel, "Test A") + let expectedCodexHome = (codexHome.path as NSString).standardizingPath XCTAssertEqual( outcome.entries.first?.resumeCommand, - "cd /tmp/project && env CODEX_HOME='\(codexHome.path)' codex resume custom-home-session -m gpt-5.5 -a never -s danger-full-access -c model_reasoning_effort=medium" + "cd /tmp/project && env CODEX_HOME=\(SessionEntry.shellQuote(expectedCodexHome)) codex resume custom-home-session -m gpt-5.5 -a never -s danger-full-access -c model_reasoning_effort=medium" ) } From 40413cebe92e22c190df0c19af2e410ed6880afb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 13 May 2026 14:11:13 -0700 Subject: [PATCH 06/16] test: align detached transfer helper with resume state --- cmuxTests/WorkspaceUnitTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index fa5ab5c787b2..1edddb0f68cb 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2456,7 +2456,7 @@ final class WorkspaceCreationWorkingDirectoryInheritanceTests: XCTestCase { customTitle: nil, manuallyUnread: false, restorableAgent: nil, - restorableAgentAutoResumePending: false, + restorableAgentResumeState: nil, agentRuntime: nil, isRemoteTerminal: false, remoteRelayPort: nil, From e52960d74f234c5c21f722c50100eca8d548a8b9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 16:50:02 -0700 Subject: [PATCH 07/16] refactor: clarify Codex sessions root matching --- Sources/SessionIndexStore+CodexSQL.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SessionIndexStore+CodexSQL.swift b/Sources/SessionIndexStore+CodexSQL.swift index 985c2928342b..5b87d9fcfe84 100644 --- a/Sources/SessionIndexStore+CodexSQL.swift +++ b/Sources/SessionIndexStore+CodexSQL.swift @@ -262,9 +262,9 @@ extension SessionIndexStore { normalizedSessionsRoot: String ) -> Bool { guard let path = record.normalizedRolloutPath else { return false } - let isUnderDefaultRoot = path == normalizedSessionsRoot + let isUnderSessionsRoot = path == normalizedSessionsRoot || path.hasPrefix(normalizedSessionsRoot + "/") - if let rgMatchedPaths, isUnderDefaultRoot { + if let rgMatchedPaths, isUnderSessionsRoot { return rgMatchedPaths.contains(path) } return fileContainsNeedle(url: URL(fileURLWithPath: path), needle: needle) From 5eee2ce9274b514c0b0ba167aaed2e092a0df598 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 14 May 2026 17:08:29 -0700 Subject: [PATCH 08/16] fix: keep Codex SQL details out of Vault errors --- Resources/Localizable.xcstrings | 8 ++++---- Sources/SessionIndexStore+CodexSQL.swift | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e6727431f379..6fe247892273 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -106679,13 +106679,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@: cannot open state_5.sqlite (%@)" + "value": "%@: Vault couldn't load sessions from this Codex home." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@: state_5.sqlite を開けません (%@)" + "value": "%@: この Codex ホームから Vault のセッションを読み込めませんでした。" } } } @@ -106696,13 +106696,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "%@: state_5.sqlite schema unsupported - %@. Falling back to file scan." + "value": "%@: Vault couldn't read sessions from this Codex home." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@: state_5.sqlite のスキーマに対応していません - %@。ファイルスキャンにフォールバックします。" + "value": "%@: この Codex ホームから Vault のセッションを読み取れませんでした。" } } } diff --git a/Sources/SessionIndexStore+CodexSQL.swift b/Sources/SessionIndexStore+CodexSQL.swift index 5b87d9fcfe84..3e118a2a8624 100644 --- a/Sources/SessionIndexStore+CodexSQL.swift +++ b/Sources/SessionIndexStore+CodexSQL.swift @@ -1,6 +1,9 @@ import Foundation +import OSLog import SQLite3 +nonisolated private let codexSQLLogger = Logger(subsystem: "ai.manaflow.cmux", category: "SessionIndexStore") + extension SessionIndexStore { private struct CodexThreadRecord: Sendable { let sessionId: String @@ -52,11 +55,15 @@ extension SessionIndexStore { var db: OpaquePointer? guard sqlite3_open_v2(snapshotDB.path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + let detail = sqliteMessage(db) ?? "unknown error" + codexSQLLogger.warning( + "Codex home database open failed: home=\(home.path, privacy: .private) database=\(dbPath, privacy: .private) error=\(detail, privacy: .private)" + ) let format = String( localized: "sessionIndex.codexHome.error.openDatabase", - defaultValue: "%@: cannot open state_5.sqlite (%@)" + defaultValue: "%@: Vault couldn't load sessions from this Codex home." ) - errorBag.add(String(format: format, home.label, sqliteMessage(db) ?? "unknown error")) + errorBag.add(String(format: format, home.label)) sqlite3_close(db) return nil } @@ -84,11 +91,15 @@ extension SessionIndexStore { var stmt: OpaquePointer? guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK, let stmt else { + let detail = sqliteMessage(db) ?? "prepare failed" + codexSQLLogger.warning( + "Codex home database schema unsupported: home=\(home.path, privacy: .private) database=\(dbPath, privacy: .private) error=\(detail, privacy: .private)" + ) let format = String( localized: "sessionIndex.codexHome.error.unsupportedSchema", - defaultValue: "%@: state_5.sqlite schema unsupported - %@. Falling back to file scan." + defaultValue: "%@: Vault couldn't read sessions from this Codex home." ) - errorBag.add(String(format: format, home.label, sqliteMessage(db) ?? "prepare failed")) + errorBag.add(String(format: format, home.label)) sqlite3_finalize(stmt) return nil } From 3e706e549b9660d972ccb9b6c2f60a5e98c08204 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:47:15 -0700 Subject: [PATCH 09/16] fix: clarify Codex home labels and warnings --- Resources/Localizable.xcstrings | 4 +-- ...rdShortcutSettingsFileStore+Template.swift | 4 +-- Sources/SessionIndexStore.swift | 31 ++++++++++++++++--- docs/agent-hooks.md | 4 +-- web/data/cmux.schema.json | 2 +- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index a827173b0419..dea8a263d744 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -106921,13 +106921,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Codex home \"%@\" is unavailable and was skipped." + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Codex home \"%@\" は利用できないためスキップしました。" + "value": "Codex home \"%@\" は利用できないためスキップしました。フォルダが存在し読み取り可能であることを確認するか、cmux.json のパスを更新または削除してください。" } } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 2177b7c1b225..2d4d356a575a 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -87,8 +87,8 @@ extension CmuxSettingsFileStore { "codex": [ "additionalHomes": [ [ - "path": "~/.codex-testA", - "displayName": "codex-testA", + "path": "~/.codex-work", + "displayName": "Work Codex", ], ], ], diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 304f476babde..72b44fb3ddcc 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -1480,10 +1480,9 @@ final class SessionIndexStore: ObservableObject { guard !homes.isEmpty, limit > 0 else { return [] } let target = offset + limit - let showSourceLabels = homes.count > 1 + let sourceLabels = codexSourceLabels(for: homes) let merged = await withTaskGroup(of: [SessionEntry].self) { group in - for home in homes { - let sourceLabel = showSourceLabels || !home.isDefault ? home.label : nil + for (home, sourceLabel) in zip(homes, sourceLabels) { group.addTask { await loadCodexEntries( home: home, @@ -1505,6 +1504,28 @@ final class SessionIndexStore: ObservableObject { return Array(merged.sorted { $0.modified > $1.modified }.dropFirst(offset).prefix(limit)) } + nonisolated private static func codexSourceLabels(for homes: [CodexSessionHome]) -> [String?] { + let showSourceLabels = homes.count > 1 + let labelCounts = Dictionary(grouping: homes.map(\.label), by: { $0 }).mapValues(\.count) + return homes.map { + codexSourceLabel( + for: $0, + showSourceLabels: showSourceLabels, + labelCounts: labelCounts + ) + } + } + + nonisolated private static func codexSourceLabel( + for home: CodexSessionHome, + showSourceLabels: Bool, + labelCounts: [String: Int] + ) -> String? { + guard showSourceLabels || !home.isDefault else { return nil } + guard (labelCounts[home.label] ?? 0) > 1 else { return home.label } + return (home.path as NSString).abbreviatingWithTildeInPath + } + /// Primary path: query Codex's own `state_5.sqlite` (`threads` table) for a /// single CODEX_HOME. Fallback (DB missing or unsupported): file scan below. nonisolated private static func loadCodexEntries( @@ -1551,7 +1572,7 @@ final class SessionIndexStore: ObservableObject { guard !home.isDefault else { return } let format = String( localized: "sessionIndex.codexHome.warning.unavailable", - defaultValue: "Codex home \"%@\" is unavailable and was skipped." + defaultValue: "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." ) let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) errorBag.add(message) @@ -1613,7 +1634,7 @@ final class SessionIndexStore: ObservableObject { if !home.isDefault, exists { let format = String( localized: "sessionIndex.codexHome.warning.unavailable", - defaultValue: "Codex home \"%@\" is unavailable and was skipped." + defaultValue: "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." ) let message = String(format: format, (home.path as NSString).abbreviatingWithTildeInPath) errorBag.add(message) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 5535f7bf4a36..a6ee6eee570e 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -85,8 +85,8 @@ isolated Codex environments in the right sidebar, add them to `cmux.json`: { "codex": { "additionalHomes": [ - "~/.codex-testA", - { "path": "~/.codex-testB", "displayName": "codex-testB" } + "~/.codex-work", + { "path": "~/.codex-personal", "displayName": "Personal Codex" } ] } } diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 5412d8ea119c..bcb0266c3842 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -332,7 +332,7 @@ "oneOf": [ { "type": "string", - "description": "Path to an additional CODEX_HOME directory, for example ~/.codex-testA." + "description": "Path to an additional CODEX_HOME directory, for example ~/.codex-work." }, { "type": "object", From cb9acf284bff1cc707e32122f5dea682c9f86626 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 02:58:54 -0700 Subject: [PATCH 10/16] docs: clarify Codex home discovery behavior --- docs/agent-hooks.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index a6ee6eee570e..706fad36e181 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -92,8 +92,14 @@ isolated Codex environments in the right sidebar, add them to `cmux.json`: } ``` -Sessions from configured homes show their source label in Vault, and resuming -one sets `CODEX_HOME` to the matching directory. +When Vault scans more than one valid Codex home, sessions from every home show +their source label, including the default home as `Default Codex`. When resuming +a session from an additional home, cmux sets `CODEX_HOME` to the matching +directory. + +Missing, unreadable, or empty additional homes do not block discovery from other +homes. Empty homes simply contribute no sessions, and invalid paths have no +effect until they are fixed or removed from `cmux.json`. Pi uses Pi's extension system, not the legacy Pi hooks API. The installed extension is auto-discovered from `~/.pi/agent/extensions/` or `$PI_CODING_AGENT_DIR/extensions/`. From c9982122cbebd7093e2c0cb7b6e1f492dd11db10 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:14:46 -0700 Subject: [PATCH 11/16] Persist normalized Codex home paths --- Sources/KeyboardShortcutSettingsFileStore.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 008541cbd5b8..3ce56d38fb63 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1397,7 +1397,14 @@ final class CmuxSettingsFileStore { } let normalized = ((trimmedPath as NSString).expandingTildeInPath as NSString).standardizingPath guard seen.insert(normalized).inserted else { continue } - homes.append(parsed) + let cleanedDisplayName = parsed.displayName? + .trimmingCharacters(in: .whitespacesAndNewlines) + homes.append( + CodexSessionHomeSetting( + path: normalized, + displayName: (cleanedDisplayName?.isEmpty == false) ? cleanedDisplayName : nil + ) + ) } return homes } From 53d2890454a199d3b3c88c9062aa6c26fff11713 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:46:55 -0700 Subject: [PATCH 12/16] refactor: observe Codex home changes with async sequence --- Sources/SessionIndexStore.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 72b44fb3ddcc..6ad020e40042 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -202,19 +202,25 @@ final class SessionIndexStore: ObservableObject { private var sectionsCacheRevision: UInt64 = 0 private var cachedSectionsRevision: UInt64? private var cachedSections: [IndexSection] = [] - private var codexSessionHomesCancellable: AnyCancellable? + private var codexSessionHomesTask: Task? init() { self.agentOrder = Self.loadAgentOrder() self.directoryOrder = Self.loadDirectoryOrder() let storedGrouping = UserDefaults.standard.string(forKey: Self.groupingKey) self.grouping = SessionGrouping(rawValue: storedGrouping ?? "") ?? .directory - codexSessionHomesCancellable = NotificationCenter.default - .publisher(for: CodexSessionHomeSettings.didChangeNotification) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in + codexSessionHomesTask = Task { @MainActor [weak self] in + let notifications = NotificationCenter.default.notifications( + named: CodexSessionHomeSettings.didChangeNotification + ) + for await _ in notifications { self?.reload() } + } + } + + deinit { + codexSessionHomesTask?.cancel() } /// Returns the sections for the current grouping mode, in the user-saved order. From 0cea5020cd0fb7bd36bd94c94b76552f78e65690 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:56:49 -0700 Subject: [PATCH 13/16] fix: add Codex home localization fallbacks --- Resources/Localizable.xcstrings | 408 ++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 85ad88aa3d32..8d64b19c9452 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -107125,68 +107125,476 @@ "sessionIndex.codexHome.defaultLabel": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Default Codex" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "既定の Codex" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "기본 Codex" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Default Codex" + } } } }, "sessionIndex.codexHome.error.openDatabase": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, "en": { "stringUnit": { "state": "translated", "value": "%@: Vault couldn't load sessions from this Codex home." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "%@: この Codex ホームから Vault のセッションを読み込めませんでした。" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: 이 Codex 홈에서 Vault 세션을 불러올 수 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't load sessions from this Codex home." + } } } }, "sessionIndex.codexHome.error.unsupportedSchema": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, "en": { "stringUnit": { "state": "translated", "value": "%@: Vault couldn't read sessions from this Codex home." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "%@: この Codex ホームから Vault のセッションを読み取れませんでした。" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@: 이 Codex 홈에서 Vault 세션을 읽을 수 없습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@: Vault couldn't read sessions from this Codex home." + } } } }, "sessionIndex.codexHome.warning.unavailable": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, "en": { "stringUnit": { "state": "translated", "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "Codex home \"%@\" は利用できないためスキップしました。フォルダが存在し読み取り可能であることを確認するか、cmux.json のパスを更新または削除してください。" } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\"을(를) 사용할 수 없어 건너뛰었습니다. 폴더가 존재하고 읽을 수 있는지 확인하거나 cmux.json에서 해당 경로를 업데이트/제거하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Codex home \"%@\" is unavailable and was skipped. Check that the folder exists and is readable, or update/remove that path in cmux.json." + } } } }, From be5a8f2a3110901a2300345617b7874bf361f2d6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:31:24 -0700 Subject: [PATCH 14/16] fix: replace vault sleep timers --- Sources/SessionIndexStore.swift | 21 ++++++++--- Sources/SessionIndexView.swift | 63 ++++++++++++++++++++++++++++----- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 6d2134284c59..9ea84ca71e8e 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -57,19 +57,32 @@ final class SessionDragRegistry { static let shared = SessionDragRegistry() private var pending: [UUID: SessionEntry] = [:] + private var expirationTimers: [UUID: DispatchSourceTimer] = [:] func register(_ entry: SessionEntry) -> UUID { let id = UUID() pending[id] = entry - // Auto-expire so a cancelled drag doesn't leak forever. - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(60)) - self?.pending.removeValue(forKey: id) + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule(deadline: .now() + .seconds(60), leeway: .seconds(1)) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.expire(id: id) + } } + expirationTimers[id] = timer + timer.resume() return id } func consume(id: UUID) -> SessionEntry? { + expirationTimers[id]?.cancel() + expirationTimers[id] = nil + pending.removeValue(forKey: id) + } + + private func expire(id: UUID) { + expirationTimers[id]?.cancel() + expirationTimers[id] = nil pending.removeValue(forKey: id) } } diff --git a/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index fa5f51f7075f..56b030c6aadc 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -2040,9 +2040,8 @@ private struct SectionPopoverView: View { // query change. `.task(id: query)` auto-cancels on view disappear // AND on any `query` change, so we don't need onAppear + // onChange + onDisappear + a manual generation counter to - // discard superseded fetches. The 200ms pause doubles as a - // debounce: rapid keystrokes bump `id:` which cancels this task - // before the sleep completes, preventing an unnecessary search. + // discard superseded fetches. A short timer-backed debounce + // prevents unnecessary searches during rapid keystrokes. .task(id: query) { // Any pagination task from the previous query lifecycle is now // superseded. Cancel explicitly; reassigning `loadTask = @@ -2108,11 +2107,8 @@ private struct SectionPopoverView: View { hasMore = true isLoading = true - do { - try await Task.sleep(for: .milliseconds(200)) - } catch { - return - } + let debounce = MainActorDelayTimer(milliseconds: 200) + guard await debounce.wait() else { return } let outcome = await search(trimmed, sectionSearchScope, 0, Self.pageSize) guard !Task.isCancelled else { return } @@ -2226,6 +2222,57 @@ private struct SectionPopoverView: View { } } +@MainActor +private final class MainActorDelayTimer: @unchecked Sendable { + private let milliseconds: Int + private var timer: DispatchSourceTimer? + private var continuation: CheckedContinuation? + private var didResume = false + + init(milliseconds: Int) { + self.milliseconds = milliseconds + } + + func wait() async -> Bool { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(returning: false) + return + } + + self.continuation = continuation + let timer = DispatchSource.makeTimerSource(queue: .main) + timer.schedule( + deadline: .now() + .milliseconds(milliseconds), + leeway: .milliseconds(15) + ) + timer.setEventHandler { [weak self] in + Task { @MainActor [weak self] in + self?.resume(returning: true) + } + } + self.timer = timer + timer.resume() + } + } onCancel: { + Task { @MainActor in + self.resume(returning: false) + } + } + } + + private func resume(returning value: Bool) { + guard !didResume else { return } + didResume = true + timer?.cancel() + timer = nil + let continuation = continuation + self.continuation = nil + continuation?.resume(returning: value) + } +} + private struct PopoverRow: View, Equatable { let entry: SessionEntry let onActivate: () -> Void From 6b71489ca5259947a77c2ed46c17e6f80474122c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 06:36:20 -0700 Subject: [PATCH 15/16] fix: return consumed vault drag entry --- Sources/SessionIndexStore.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 9ea84ca71e8e..04ee2f7698b3 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -77,7 +77,7 @@ final class SessionDragRegistry { func consume(id: UUID) -> SessionEntry? { expirationTimers[id]?.cancel() expirationTimers[id] = nil - pending.removeValue(forKey: id) + return pending.removeValue(forKey: id) } private func expire(id: UUID) { From 31a95e8d21311cc409cfec55bd3d60adcc2ecc8a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 07:09:58 -0700 Subject: [PATCH 16/16] fix: avoid codex home notification warning --- Sources/SessionIndexStore.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 04ee2f7698b3..21436a714d02 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -218,25 +218,28 @@ final class SessionIndexStore: ObservableObject { private var sectionsCacheRevision: UInt64 = 0 private var cachedSectionsRevision: UInt64? private var cachedSections: [IndexSection] = [] - private var codexSessionHomesTask: Task? + private var codexSessionHomesObserver: NSObjectProtocol? init() { self.agentOrder = Self.loadAgentOrder() self.directoryOrder = Self.loadDirectoryOrder() let storedGrouping = UserDefaults.standard.string(forKey: Self.groupingKey) self.grouping = SessionGrouping(rawValue: storedGrouping ?? "") ?? .directory - codexSessionHomesTask = Task { @MainActor [weak self] in - let notifications = NotificationCenter.default.notifications( - named: CodexSessionHomeSettings.didChangeNotification - ) - for await _ in notifications { + codexSessionHomesObserver = NotificationCenter.default.addObserver( + forName: CodexSessionHomeSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.reload() } } } deinit { - codexSessionHomesTask?.cancel() + if let codexSessionHomesObserver { + NotificationCenter.default.removeObserver(codexSessionHomesObserver) + } } /// Returns the sections for the current grouping mode, in the user-saved order.