diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index db674105e604..11002128530b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -109575,6 +109575,482 @@ } } }, + "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." + } + } + } + }, "sessionIndex.agent.opencode": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 872758d3b1f0..fab9321eabbe 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -70,6 +70,7 @@ extension CmuxSettingsFileStore { "terminal.showScrollBar", "terminal.autoResumeAgentSessions", "terminal.resumeCommands", + "codex.additionalHomes", "notifications.dockBadge", "notifications.showInMenuBar", "notifications.unreadPaneRing", diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 30eb38b54bb3..8455450ca7f3 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -84,6 +84,16 @@ extension CmuxSettingsFileStore { "resumeCommands": [], ], ], + [ + "codex": [ + "additionalHomes": [ + [ + "path": "~/.codex-work", + "displayName": "Work Codex", + ], + ], + ], + ], [ "notifications": [ "dockBadge": NotificationBadgeSettings.defaultDockBadgeEnabled, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index d23945c28ecd..a135e95b57d1 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -325,6 +325,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) } @@ -486,6 +489,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, @@ -1272,6 +1289,7 @@ final class CmuxSettingsFileStore { let changes = sideEffects.changes let apply = { var agentSessionAutoResumeDidChange = false + var codexSessionHomesDidChange = false for change in changes { if change.defaultsKey == TerminalScrollBarSettings.showScrollBarKey { TerminalScrollBarSettings.notifyDidChange(notificationCenter: notificationCenter) @@ -1281,6 +1299,10 @@ final class CmuxSettingsFileStore { agentSessionAutoResumeDidChange = true } + if change.defaultsKey == CodexSessionHomeSettings.additionalHomesKey { + codexSessionHomesDidChange = true + } + if change.defaultsKey == LanguageSettings.languageKey { let rawValue = UserDefaults.standard.string(forKey: change.defaultsKey) ?? "" LanguageSettings.apply(AppLanguage(rawValue: rawValue) ?? .system) @@ -1292,6 +1314,9 @@ final class CmuxSettingsFileStore { if agentSessionAutoResumeDidChange { AgentSessionAutoResumeSettings.notifyDidChange(notificationCenter: notificationCenter) } + if codexSessionHomesDidChange { + CodexSessionHomeSettings.notifyDidChange(notificationCenter: notificationCenter) + } } if Thread.isMainThread { apply() @@ -1388,6 +1413,50 @@ 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 } + let cleanedDisplayName = parsed.displayName? + .trimmingCharacters(in: .whitespacesAndNewlines) + homes.append( + CodexSessionHomeSetting( + path: normalized, + displayName: (cleanedDisplayName?.isEmpty == false) ? cleanedDisplayName : nil + ) + ) + } + return homes + } + } typealias KeyboardShortcutSettingsFileStore = CmuxSettingsFileStore diff --git a/Sources/SessionIndexModels.swift b/Sources/SessionIndexModels.swift index baaddd5a897d..f1d580577022 100644 --- a/Sources/SessionIndexModels.swift +++ b/Sources/SessionIndexModels.swift @@ -186,6 +186,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 { @@ -197,7 +276,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?, configDirectoryForResume: String?) - case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?) + case codex(model: String?, approvalPolicy: String?, sandboxMode: String?, effort: String?, codexHome: String?) case grok(model: String?, permissionMode: String?, sandboxMode: String?, grokHome: String?) case opencode(providerModel: String?, agentName: String?) case rovodev @@ -264,6 +343,33 @@ struct SessionEntry: Identifiable, Hashable { let modified: Date let fileURL: URL? let specifics: AgentSpecifics + 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 } @@ -293,7 +399,8 @@ struct SessionEntry: Identifiable, Hashable { model: model, permissionMode: permissionMode, configDirectoryForResume: configDirectory - ) + ), + sourceLabel: sourceLabel ) } @@ -326,7 +433,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))") @@ -340,7 +447,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 .grok(model, permissionMode, sandboxMode, grokHome): var argv = ["grok", "-r", sessionId] if let model, !model.isEmpty { @@ -544,4 +652,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..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 @@ -27,9 +30,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 +55,15 @@ 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 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: "%@: Vault couldn't load sessions from this Codex home." + ) + errorBag.add(String(format: format, home.label)) sqlite3_close(db) return nil } @@ -76,7 +91,15 @@ 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 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: "%@: Vault couldn't read sessions from this Codex home." + ) + errorBag.add(String(format: format, home.label)) sqlite3_finalize(stmt) return nil } @@ -104,7 +127,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 +147,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 +162,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 +180,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 +209,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 +222,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 ) } @@ -234,9 +273,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) diff --git a/Sources/SessionIndexStore.swift b/Sources/SessionIndexStore.swift index 75e2f3ea8789..ca9a40ad03f5 100644 --- a/Sources/SessionIndexStore.swift +++ b/Sources/SessionIndexStore.swift @@ -102,19 +102,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 + return pending.removeValue(forKey: id) + } + + private func expire(id: UUID) { + expirationTimers[id]?.cancel() + expirationTimers[id] = nil pending.removeValue(forKey: id) } } @@ -250,12 +263,28 @@ final class SessionIndexStore: ObservableObject { private var sectionsCacheRevision: UInt64 = 0 private var cachedSectionsRevision: UInt64? private var cachedSections: [IndexSection] = [] + 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 + codexSessionHomesObserver = NotificationCenter.default.addObserver( + forName: CodexSessionHomeSettings.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.reload() + } + } + } + + deinit { + if let codexSessionHomesObserver { + NotificationCenter.default.removeObserver(codexSessionHomesObserver) + } } /// Returns the sections for the current grouping mode, in the user-saved order. @@ -1533,26 +1562,126 @@ 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 sourceLabels = codexSourceLabels(for: homes) + let merged = await withTaskGroup(of: [SessionEntry].self) { group in + for (home, sourceLabel) in zip(homes, sourceLabels) { + 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)) + } + + 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( + 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. 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) + sessionIndexLogger.warning("Codex home unavailable and skipped: \(home.path, privacy: .private)") + 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), @@ -1565,9 +1694,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 @@ -1586,7 +1721,19 @@ final class SessionIndexStore: ObservableObject { at: rootURL, includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], options: [.skipsHiddenFiles] - ) else { return [] } + ) else { + 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. 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) + sessionIndexLogger.warning("Codex sessions root unavailable and skipped: \(root, privacy: .private)") + } + return [] + } for case let url as URL in enumerator { guard url.pathExtension == "jsonl" else { continue } let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) @@ -1633,13 +1780,45 @@ 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)) } + #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/Sources/SessionIndexView.swift b/Sources/SessionIndexView.swift index f87a6a567649..1bb47110a544 100644 --- a/Sources/SessionIndexView.swift +++ b/Sources/SessionIndexView.swift @@ -561,6 +561,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()) @@ -629,6 +638,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) } @@ -747,8 +759,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) @@ -2251,9 +2263,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 = @@ -2319,11 +2330,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 } @@ -2437,6 +2445,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 @@ -2485,6 +2544,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 } @@ -2498,7 +2566,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 0db2b2d09e53..b8598f9b718c 100644 --- a/cmuxTests/SessionIndexViewTests.swift +++ b/cmuxTests/SessionIndexViewTests.swift @@ -186,6 +186,66 @@ 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") + let expectedCodexHome = (codexHome.path as NSString).standardizingPath + XCTAssertEqual( + outcome.entries.first?.resumeCommand, + "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" + ) + } + + 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() @@ -396,7 +456,7 @@ private extension SessionAgent { configDirectoryForResume: claudeConfigDirectoryForResume ) 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 .grok: return .grok(model: nil, permissionMode: nil, sandboxMode: nil, grokHome: nil) case .opencode: diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 0119b6382755..7eaa12e75d4f 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -88,6 +88,29 @@ 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-work", + { "path": "~/.codex-personal", "displayName": "Personal Codex" } + ] + } +} +``` + +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/`. ## Troubleshooting diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 934556cbda0a..22dd84f50f73 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -386,6 +386,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-work." + }, + { + "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.",