diff --git a/Sources/CodexBarClaudeWebProbe/main.swift b/Sources/CodexBarClaudeWebProbe/main.swift index 63d9ce867..81aab4a16 100644 --- a/Sources/CodexBarClaudeWebProbe/main.swift +++ b/Sources/CodexBarClaudeWebProbe/main.swift @@ -1,7 +1,6 @@ import CodexBarCore import Foundation -@main enum CodexBarClaudeWebProbe { private static let defaultEndpoints: [String] = [ "https://claude.ai/api/organizations", @@ -19,7 +18,7 @@ enum CodexBarClaudeWebProbe { "https://claude.ai/settings/usage", ] - static func main() async { + static func run() async { let args = CommandLine.arguments.dropFirst() let endpoints = args.isEmpty ? Self.defaultEndpoints : Array(args) let includePreview = ProcessInfo.processInfo.environment["CLAUDE_WEB_PROBE_PREVIEW"] == "1" @@ -61,3 +60,5 @@ enum CodexBarClaudeWebProbe { print("") } } +await CodexBarClaudeWebProbe.run() + diff --git a/Sources/CodexBarCore/PathEnvironment.swift b/Sources/CodexBarCore/PathEnvironment.swift index ece93eda2..7f3797d32 100644 --- a/Sources/CodexBarCore/PathEnvironment.swift +++ b/Sources/CodexBarCore/PathEnvironment.swift @@ -52,6 +52,7 @@ public enum BinaryLocator { loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, + useShellCache: true, fileManager: fileManager, home: home) } @@ -72,6 +73,7 @@ public enum BinaryLocator { loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, + useShellCache: true, fileManager: fileManager, home: home) } @@ -92,6 +94,7 @@ public enum BinaryLocator { loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, + useShellCache: true, fileManager: fileManager, home: home) } @@ -112,6 +115,7 @@ public enum BinaryLocator { loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, + useShellCache: true, fileManager: fileManager, home: home) } @@ -124,6 +128,7 @@ public enum BinaryLocator { loginPATH: [String]?, commandV: (String, String?, TimeInterval, FileManager) -> String?, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String?, + useShellCache: Bool, fileManager: FileManager, home: String) -> String? { @@ -150,18 +155,48 @@ public enum BinaryLocator { return pathHit } - // 4) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) - if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), - fileManager.isExecutableFile(atPath: shellHit) - { - return shellHit - } - - // 4b) Alias fallback (login shell); only attempt after all standard lookups fail. - if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), - fileManager.isExecutableFile(atPath: aliasHit) - { - return aliasHit + // 4) Interactive login shell lookup — use cache to avoid repeated shell spawns + // (shell auto-start hooks like zellij create new daemon instances on each invocation). + // Cache is bypassed when custom commandV/aliasResolver closures are injected (e.g. tests) + // to prevent a cached result from one call context affecting another. + if useShellCache { + let cached = BinaryResolutionCache.shared.cachedResult(for: name) + if let cached { + if let path = cached.path { + if fileManager.isExecutableFile(atPath: path) { + return path + } + // Cached path is no longer executable (e.g. CLI uninstalled/moved) — retry + BinaryResolutionCache.shared.invalidate(name) + } else { + // Cached nil = confirmed not found; skip shell spawns + return nil + } + } + // Not yet cached (or just invalidated) — run the shell lookups once and cache the result + var resolved: String? = nil + if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), + fileManager.isExecutableFile(atPath: shellHit) + { + resolved = shellHit + } else if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), + fileManager.isExecutableFile(atPath: aliasHit) + { + resolved = aliasHit + } + BinaryResolutionCache.shared.store(path: resolved, for: name) + if let resolved { return resolved } + } else { + if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), + fileManager.isExecutableFile(atPath: shellHit) + { + return shellHit + } + if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), + fileManager.isExecutableFile(atPath: aliasHit) + { + return aliasHit + } } // 5) Minimal fallback @@ -440,6 +475,44 @@ enum LoginShellPathCapturer { } } +/// Caches the results of interactive-shell binary lookups (steps 4/4b of resolveBinary). +/// Prevents repeated login-shell spawns — which can trigger auto-start hooks like zellij — +/// on every refresh cycle when a binary is not found on the standard PATH. +final class BinaryResolutionCache: @unchecked Sendable { + static let shared = BinaryResolutionCache() + + private let lock = NSLock() + // nil value = binary confirmed not found; missing key = not yet looked up + private var cache: [String: String?] = [:] + + func cachedResult(for name: String) -> (found: Bool, path: String?)? { + lock.lock() + defer { lock.unlock() } + guard cache.keys.contains(name) else { return nil } + return (found: cache[name] != nil, path: cache[name] ?? nil) + } + + func store(path: String?, for name: String) { + lock.lock() + defer { lock.unlock() } + cache[name] = path + } + + /// Resets a single entry (e.g., when a binary is installed/uninstalled at runtime). + func invalidate(_ name: String) { + lock.lock() + defer { lock.unlock() } + cache.removeValue(forKey: name) + } + + /// Resets all cached entries. + func invalidateAll() { + lock.lock() + defer { lock.unlock() } + cache.removeAll() + } +} + public final class LoginShellPathCache: @unchecked Sendable { public static let shared = LoginShellPathCache()