import Foundation import CryptoKit import Darwin #if canImport(LocalAuthentication) import LocalAuthentication #endif #if canImport(Security) import Security #endif #if canImport(Sentry) import Sentry #endif struct CLIError: Error, CustomStringConvertible { let message: String var description: String { message } } private final class CLISocketSentryTelemetry { private let command: String private let subcommand: String private let socketPath: String private let envSocketPath: String? private let workspaceId: String? private let surfaceId: String? private let disabledByEnv: Bool #if canImport(Sentry) private static let startupLock = NSLock() private static var started = false private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" private static func currentSentryReleaseName() -> String? { guard let bundleIdentifier = currentSentryBundleIdentifier(), let version = currentBundleVersionValue(forKey: "CFBundleShortVersionString"), let build = currentBundleVersionValue(forKey: "CFBundleVersion") else { return nil } return "\(bundleIdentifier)@\(version)+\(build)" } private static func currentSentryBundleIdentifier() -> String? { if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]? .trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } if let bundleIdentifier = currentSentryBundle()?.bundleIdentifier? .trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } return nil } private static func currentBundleVersionValue(forKey key: String) -> String? { guard let value = currentSentryBundle()?.infoDictionary?[key] as? String else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !trimmed.contains("$(") else { return nil } return trimmed } private static func currentSentryBundle() -> Bundle? { if Bundle.main.bundleIdentifier?.isEmpty == false { return Bundle.main } guard let executableURL = currentExecutableURL() else { return Bundle.main } var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.pathExtension == "app", let bundle = Bundle(url: current) { return bundle } if current.lastPathComponent == "Contents" { let appURL = current.deletingLastPathComponent().standardizedFileURL if appURL.pathExtension == "app", let bundle = Bundle(url: appURL) { return bundle } } guard let parent = parentSearchURL(for: current) else { break } current = parent } return Bundle.main } private static func currentExecutableURL() -> URL? { var size: UInt32 = 0 _ = _NSGetExecutablePath(nil, &size) if size > 0 { var buffer = Array(repeating: 0, count: Int(size)) if _NSGetExecutablePath(&buffer, &size) == 0 { return URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL } } return Bundle.main.executableURL?.standardizedFileURL } private static func parentSearchURL(for url: URL) -> URL? { let standardized = url.standardizedFileURL let path = standardized.path guard !path.isEmpty, path != "/" else { return nil } let parent = standardized.deletingLastPathComponent().standardizedFileURL guard parent.path != path else { return nil } return parent } #endif init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { self.command = command.lowercased() self.subcommand = commandArgs.first?.lowercased() ?? "help" self.socketPath = socketPath self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] ?? processEnv["CMUX_SOCKET"] self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] self.surfaceId = processEnv["CMUX_SURFACE_ID"] self.disabledByEnv = processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" || processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" } func breadcrumb(_ message: String, data: [String: Any] = [:]) { guard shouldEmit else { return } #if canImport(Sentry) Self.ensureStarted() var payload = baseContext() for (key, value) in data { payload[key] = value } let crumb = Breadcrumb(level: .info, category: "cmux.cli") crumb.message = message crumb.data = payload SentrySDK.addBreadcrumb(crumb) #endif } func captureError(stage: String, error: Error) { guard shouldEmit else { return } #if canImport(Sentry) Self.ensureStarted() var context = baseContext() context["stage"] = stage context["error"] = String(describing: error) for (key, value) in socketDiagnostics() { context[key] = value } let subcommand = self.subcommand let command = self.command _ = SentrySDK.capture(error: error) { scope in scope.setLevel(.error) scope.setTag(value: "cmux-cli", key: "component") scope.setTag(value: command, key: "cli_command") scope.setTag(value: subcommand, key: "cli_subcommand") scope.setContext(value: context, key: "cli_socket") } SentrySDK.flush(timeout: 2.0) #endif } private var shouldEmit: Bool { !disabledByEnv } private func baseContext() -> [String: Any] { var context: [String: Any] = [ "command": command, "subcommand": subcommand, "requested_socket_path": socketPath, "env_socket_path": envSocketPath ?? "" ] if let workspaceId { context["workspace_id"] = workspaceId } if let surfaceId { context["surface_id"] = surfaceId } return context } private func socketDiagnostics() -> [String: Any] { var context: [String: Any] = [ "cwd": FileManager.default.currentDirectoryPath, "uid": Int(getuid()), "euid": Int(geteuid()) ] var st = stat() if lstat(socketPath, &st) == 0 { context["socket_exists"] = true context["socket_mode"] = String(format: "%o", Int(st.st_mode & 0o7777)) context["socket_owner_uid"] = Int(st.st_uid) context["socket_owner_gid"] = Int(st.st_gid) context["socket_file_type"] = Self.fileTypeDescription(mode: st.st_mode) } else { let code = errno context["socket_exists"] = false context["socket_errno"] = Int(code) context["socket_errno_description"] = String(cString: strerror(code)) } let tmpSockets = Self.discoverSockets(in: "/tmp", limit: 10) if !tmpSockets.isEmpty { context["tmp_cmux_sockets"] = tmpSockets } let taggedSockets = tmpSockets.filter { $0 != CLISocketPathResolver.legacyDefaultSocketPath } if CLISocketPathResolver.isImplicitDefaultPath(socketPath), (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), !taggedSockets.isEmpty { context["possible_root_cause"] = "CMUX_SOCKET_PATH/CMUX_SOCKET missing while tagged sockets exist" } return context } private static func fileTypeDescription(mode: mode_t) -> String { switch mode & mode_t(S_IFMT) { case mode_t(S_IFSOCK): return "socket" case mode_t(S_IFREG): return "regular" case mode_t(S_IFDIR): return "directory" case mode_t(S_IFLNK): return "symlink" default: return "other" } } private static func discoverSockets(in directory: String, limit: Int) -> [String] { guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else { return [] } var sockets: [String] = [] for name in entries.sorted() { guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } let fullPath = URL(fileURLWithPath: directory) .appendingPathComponent(name, isDirectory: false) .path var st = stat() guard lstat(fullPath, &st) == 0 else { continue } guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } sockets.append(fullPath) if sockets.count >= limit { break } } return sockets } #if canImport(Sentry) private static func ensureStarted() { startupLock.lock() defer { startupLock.unlock() } guard !started else { return } SentrySDK.start { options in options.dsn = dsn options.releaseName = currentSentryReleaseName() #if DEBUG options.environment = "development-cli" #else options.environment = "production-cli" #endif options.debug = false options.sendDefaultPii = true options.attachStacktrace = true options.tracesSampleRate = 0.0 } started = true } #endif } struct WindowInfo { let index: Int let id: String let key: Bool let selectedWorkspaceId: String? let workspaceCount: Int } struct NotificationInfo { let id: String let workspaceId: String let surfaceId: String? let isRead: Bool let title: String let subtitle: String let body: String } private struct ClaudeHookParsedInput { let rawInput: String let object: [String: Any]? let sessionId: String? let cwd: String? let transcriptPath: String? } private struct ClaudeHookSessionRecord: Codable { var sessionId: String var workspaceId: String var surfaceId: String var cwd: String? var pid: Int? var lastSubtitle: String? var lastBody: String? var startedAt: TimeInterval var updatedAt: TimeInterval } private struct ClaudeHookSessionStoreFile: Codable { var version: Int = 1 var sessions: [String: ClaudeHookSessionRecord] = [:] } private final class ClaudeHookSessionStore { private static let defaultStatePath = "~/.cmuxterm/claude-hook-sessions.json" private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 private let statePath: String private let fileManager: FileManager private let decoder = JSONDecoder() private let encoder = JSONEncoder() init( processEnv: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default ) { if let overridePath = processEnv["CMUX_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), !overridePath.isEmpty { self.statePath = NSString(string: overridePath).expandingTildeInPath } else { self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath } self.fileManager = fileManager self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] } func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { let normalized = normalizeSessionId(sessionId) guard !normalized.isEmpty else { return nil } return try withLockedState { state in state.sessions[normalized] } } func upsert( sessionId: String, workspaceId: String, surfaceId: String, cwd: String?, pid: Int? = nil, lastSubtitle: String? = nil, lastBody: String? = nil ) throws { let normalized = normalizeSessionId(sessionId) guard !normalized.isEmpty else { return } try withLockedState { state in let now = Date().timeIntervalSince1970 var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( sessionId: normalized, workspaceId: workspaceId, surfaceId: surfaceId, cwd: nil, pid: nil, lastSubtitle: nil, lastBody: nil, startedAt: now, updatedAt: now ) record.workspaceId = workspaceId if !surfaceId.isEmpty { record.surfaceId = surfaceId } if let cwd = normalizeOptional(cwd) { record.cwd = cwd } if let pid { record.pid = pid } if let subtitle = normalizeOptional(lastSubtitle) { record.lastSubtitle = subtitle } if let body = normalizeOptional(lastBody) { record.lastBody = body } record.updatedAt = now state.sessions[normalized] = record } } func consume( sessionId: String?, workspaceId: String?, surfaceId: String? ) throws -> ClaudeHookSessionRecord? { let normalizedSessionId = normalizeOptional(sessionId) let normalizedWorkspace = normalizeOptional(workspaceId) let normalizedSurface = normalizeOptional(surfaceId) return try withLockedState { state in if let normalizedSessionId, let removed = state.sessions.removeValue(forKey: normalizedSessionId) { return removed } guard let fallback = fallbackRecord( sessions: Array(state.sessions.values), workspaceId: normalizedWorkspace, surfaceId: normalizedSurface ) else { return nil } state.sessions.removeValue(forKey: fallback.sessionId) return fallback } } private func fallbackRecord( sessions: [ClaudeHookSessionRecord], workspaceId: String?, surfaceId: String? ) -> ClaudeHookSessionRecord? { if let surfaceId { let matches = sessions.filter { $0.surfaceId == surfaceId } return matches.max(by: { $0.updatedAt < $1.updatedAt }) } if let workspaceId { let matches = sessions.filter { $0.workspaceId == workspaceId } if matches.count == 1 { return matches[0] } } return nil } private func withLockedState(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T { let lockPath = statePath + ".lock" let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) if fd < 0 { throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)") } defer { Darwin.close(fd) } if flock(fd, LOCK_EX) != 0 { throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)") } defer { _ = flock(fd, LOCK_UN) } var state = loadUnlocked() pruneExpired(&state) let result = try body(&state) try saveUnlocked(state) return result } private func loadUnlocked() -> ClaudeHookSessionStoreFile { guard fileManager.fileExists(atPath: statePath) else { return ClaudeHookSessionStoreFile() } guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)), let decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else { return ClaudeHookSessionStoreFile() } return decoded } private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws { let stateURL = URL(fileURLWithPath: statePath) let parentURL = stateURL.deletingLastPathComponent() try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil) let data = try encoder.encode(state) try data.write(to: stateURL, options: .atomic) } private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) { let now = Date().timeIntervalSince1970 let cutoff = now - Self.maxStateAgeSeconds state.sessions = state.sessions.filter { _, record in record.updatedAt >= cutoff } } private func normalizeSessionId(_ value: String) -> String { value.trimmingCharacters(in: .whitespacesAndNewlines) } private func normalizeOptional(_ value: String?) -> String? { guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } return value } } enum CLIIDFormat: String { case refs case uuids case both static func parse(_ raw: String?) throws -> CLIIDFormat? { guard let raw else { return nil } guard let parsed = CLIIDFormat(rawValue: raw.lowercased()) else { throw CLIError(message: "--id-format must be one of: refs, uuids, both") } return parsed } } enum SocketPasswordResolver { private static let service = "com.cmuxterm.app.socket-control" private static let account = "local-socket-password" private static let directoryName = "cmux" private static let fileName = "socket-control-password" static func resolve(explicit: String?, socketPath: String) -> String? { if let explicit = normalized(explicit) { return explicit } if let env = normalized(ProcessInfo.processInfo.environment["CMUX_SOCKET_PASSWORD"]) { return env } if let filePassword = loadFromFile() { return filePassword } return loadFromKeychain(socketPath: socketPath) } private static func normalized(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .newlines) return trimmed.isEmpty ? nil : trimmed } private static func loadFromFile() -> String? { guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let passwordURL = appSupport .appendingPathComponent(directoryName, isDirectory: true) .appendingPathComponent(fileName, isDirectory: false) guard let data = try? Data(contentsOf: passwordURL) else { return nil } guard let value = String(data: data, encoding: .utf8) else { return nil } return normalized(value) } static func keychainServices( socketPath: String, environment: [String: String] = ProcessInfo.processInfo.environment ) -> [String] { guard let scope = keychainScope(socketPath: socketPath, environment: environment) else { return [service] } return ["\(service).\(scope)", service] } private static func keychainScope( socketPath: String, environment: [String: String] = ProcessInfo.processInfo.environment ) -> String? { if let tag = normalized(environment["CMUX_TAG"]) { let scoped = sanitizeScope(tag) if !scoped.isEmpty { return scoped } } let candidate = URL(fileURLWithPath: socketPath).lastPathComponent let prefixes = ["cmux-debug-", "cmux-"] for prefix in prefixes { guard candidate.hasPrefix(prefix), candidate.hasSuffix(".sock") else { continue } let start = candidate.index(candidate.startIndex, offsetBy: prefix.count) let end = candidate.index(candidate.endIndex, offsetBy: -".sock".count) guard start < end else { continue } let rawScope = String(candidate[start.. String { let lowered = raw.lowercased() let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-")) let mappedScalars = lowered.unicodeScalars.map { scalar -> Character in allowed.contains(scalar) ? Character(scalar) : "." } var normalizedScope = String(mappedScalars) normalizedScope = normalizedScope.replacingOccurrences( of: "\\.+", with: ".", options: .regularExpression ) normalizedScope = normalizedScope.trimmingCharacters(in: CharacterSet(charactersIn: ".")) return normalizedScope } private static func loadFromKeychain(socketPath: String) -> String? { for service in keychainServices(socketPath: socketPath) { let authContext = LAContext() authContext.interactionNotAllowed = true let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, // Never trigger keychain UI from CLI commands; fail fast instead. kSecUseAuthenticationContext as String: authContext, ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound || status == errSecInteractionNotAllowed || status == errSecAuthFailed { continue } guard status == errSecSuccess else { continue } guard let data = result as? Data, let password = String(data: data, encoding: .utf8) else { continue } return password } return nil } } private enum CLISocketPathSource { case explicitFlag case environment case implicitDefault } private enum CLISocketPathResolver { private static let appSupportDirectoryName = "cmux" private static let stableSocketFileName = "cmux.sock" private static let lastSocketPathFileName = "last-socket-path" static let legacyDefaultSocketPath = "/tmp/cmux.sock" private static let fallbackSocketPath = "/tmp/cmux-debug.sock" private static let stagingSocketPath = "/tmp/cmux-staging.sock" private static let legacyLastSocketPathFile = "/tmp/cmux-last-socket-path" static var defaultSocketPath: String { let stablePath: String? = stableSocketDirectoryURL()? .appendingPathComponent(stableSocketFileName, isDirectory: false) .path return stablePath ?? legacyDefaultSocketPath } static func isImplicitDefaultPath(_ path: String) -> Bool { path == defaultSocketPath || path == legacyDefaultSocketPath } static func resolve( requestedPath: String, source: CLISocketPathSource, environment: [String: String] = ProcessInfo.processInfo.environment ) -> String { guard source == .implicitDefault else { return requestedPath } let candidates = dedupe(candidatePaths(requestedPath: requestedPath, environment: environment)) // Prefer sockets that are currently accepting connections. for path in candidates where canConnect(to: path) { return path } // If the listener is still starting, prefer existing socket files. for path in candidates where isSocketFile(path) { return path } return requestedPath } private static func candidatePaths(requestedPath: String, environment: [String: String]) -> [String] { var candidates: [String] = [] if let tag = normalized(environment["CMUX_TAG"]) { let slug = sanitizeTagSlug(tag) candidates.append("/tmp/cmux-debug-\(slug).sock") candidates.append("/tmp/cmux-\(slug).sock") } candidates.append(requestedPath) candidates.append(defaultSocketPath) candidates.append(legacyDefaultSocketPath) candidates.append(fallbackSocketPath) candidates.append(stagingSocketPath) candidates.append(contentsOf: discoverTaggedSockets(limit: 12)) if let last = readLastSocketPath() { candidates.append(last) } return candidates } private static func readLastSocketPath() -> String? { let primaryCandidate: String? = stableSocketDirectoryURL()? .appendingPathComponent(lastSocketPathFileName, isDirectory: false) .path let candidates = [primaryCandidate, legacyLastSocketPathFile].compactMap { $0 } for candidate in candidates { guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else { continue } if let value = normalized(data) { return value } } return nil } private static func discoverTaggedSockets(limit: Int) -> [String] { var discovered: [(path: String, mtime: TimeInterval)] = [] for directory in socketDiscoveryDirectories() { guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else { continue } discovered.reserveCapacity(min(limit, discovered.count + entries.count)) for name in entries where name.hasPrefix("cmux") && name.hasSuffix(".sock") { let path = URL(fileURLWithPath: directory) .appendingPathComponent(name, isDirectory: false) .path var st = stat() guard lstat(path, &st) == 0 else { continue } guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue } if path == defaultSocketPath || path == legacyDefaultSocketPath || path == fallbackSocketPath || path == stagingSocketPath { continue } let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000 discovered.append((path: path, mtime: modified)) } } discovered.sort { $0.mtime > $1.mtime } return dedupe(discovered.prefix(limit).map(\.path)) } private static func isSocketFile(_ path: String) -> Bool { var st = stat() return lstat(path, &st) == 0 && (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) } private static func canConnect(to path: String) -> Bool { guard isSocketFile(path) else { return false } let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return false } defer { Darwin.close(fd) } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let maxLength = MemoryLayout.size(ofValue: addr.sun_path) path.withCString { ptr in withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) strncpy(buf, ptr, maxLength - 1) } } let result = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in Darwin.connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) } } return result == 0 } private static func sanitizeTagSlug(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let slug = trimmed .replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression) .replacingOccurrences(of: "-+", with: "-", options: .regularExpression) .trimmingCharacters(in: CharacterSet(charactersIn: "-")) return slug.isEmpty ? "agent" : slug } private static func normalized(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func stableSocketDirectoryURL() -> URL? { guard let appSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } return appSupportDirectory.appendingPathComponent(appSupportDirectoryName, isDirectory: true) } private static func socketDiscoveryDirectories() -> [String] { let appSupportSocketDirectory: String = stableSocketDirectoryURL()?.path ?? "" return dedupe([ "/tmp", appSupportSocketDirectory, ]) } private static func dedupe(_ paths: [String]) -> [String] { var seen: Set = [] var ordered: [String] = [] ordered.reserveCapacity(paths.count) for path in paths where !path.isEmpty { if seen.insert(path).inserted { ordered.append(path) } } return ordered } } final class SocketClient { private let path: String private var socketFD: Int32 = -1 private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 private static let responseTimeoutSeconds: TimeInterval = { let env = ProcessInfo.processInfo.environment if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], let seconds = Double(raw), seconds > 0 { return seconds } return defaultResponseTimeoutSeconds }() init(path: String) { self.path = path } var socketPath: String { path } func connect() throws { if socketFD >= 0 { return } try connectOnce() } func close() { if socketFD >= 0 { Darwin.close(socketFD) socketFD = -1 } } func send(command: String) throws -> String { guard socketFD >= 0 else { throw CLIError(message: "Not connected") } let payload = command + "\n" try payload.withCString { ptr in let sent = Darwin.write(socketFD, ptr, strlen(ptr)) if sent < 0 { throw CLIError(message: "Failed to write to socket") } } var data = Data() var sawNewline = false while true { try configureReceiveTimeout( sawNewline ? Self.multilineResponseIdleTimeoutSeconds : Self.responseTimeoutSeconds ) var buffer = [UInt8](repeating: 0, count: 8192) let count = Darwin.read(socketFD, &buffer, buffer.count) if count < 0 { if errno == EINTR { continue } if errno == EAGAIN || errno == EWOULDBLOCK { if sawNewline { break } throw CLIError(message: "Command timed out") } throw CLIError(message: "Socket read error") } if count == 0 { break } data.append(buffer, count: count) if data.contains(UInt8(0x0A)) { sawNewline = true } } guard var response = String(data: data, encoding: .utf8) else { throw CLIError(message: "Invalid UTF-8 response") } if response.hasSuffix("\n") { response.removeLast() } return response } private func connectOnce() throws { // Verify socket is owned by the current user to prevent fake-socket attacks. var st = stat() guard stat(path, &st) == 0 else { throw CLIError(message: "Socket not found at \(path)") } guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { throw CLIError(message: "Path exists at \(path) but is not a Unix socket") } guard st.st_uid == getuid() else { throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") } socketFD = socket(AF_UNIX, SOCK_STREAM, 0) if socketFD < 0 { throw CLIError(message: "Failed to create socket") } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let maxLength = MemoryLayout.size(ofValue: addr.sun_path) path.withCString { ptr in withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) strncpy(buf, ptr, maxLength - 1) } } let result = withUnsafePointer(to: &addr) { ptr in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout.size)) } } if result == 0 { return } let connectErrno = errno Darwin.close(socketFD) socketFD = -1 throw CLIError( message: "Failed to connect to socket at \(path) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" ) } private func configureReceiveTimeout(_ timeout: TimeInterval) throws { var interval = timeval( tv_sec: Int(timeout.rounded(.down)), tv_usec: __darwin_suseconds_t((timeout - floor(timeout)) * 1_000_000) ) let result = withUnsafePointer(to: &interval) { ptr in setsockopt( socketFD, SOL_SOCKET, SO_RCVTIMEO, ptr, socklen_t(MemoryLayout.size) ) } guard result == 0 else { throw CLIError(message: "Failed to configure socket receive timeout") } } static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { let client = SocketClient(path: path) if (try? client.connect()) != nil { return client } guard let watchDirectory = existingWatchDirectory(forPath: path) else { throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") } let watchFD = open(watchDirectory, O_EVTONLY) guard watchFD >= 0 else { throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") } let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)") let semaphore = DispatchSemaphore(value: 0) var connected = false let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: watchFD, eventMask: [.write, .rename, .delete, .attrib, .extend, .link], queue: queue ) func attemptConnect() { guard !connected else { return } if (try? client.connect()) != nil { connected = true semaphore.signal() } } source.setEventHandler { attemptConnect() } source.setCancelHandler { Darwin.close(watchFD) } source.resume() queue.async { attemptConnect() } guard semaphore.wait(timeout: .now() + timeout) == .success else { source.cancel() client.close() throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") } source.cancel() return client } static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws { if FileManager.default.fileExists(atPath: path) { return } guard let watchDirectory = existingWatchDirectory(forPath: path) else { throw CLIError(message: "Timed out waiting for \(path)") } let watchFD = open(watchDirectory, O_EVTONLY) guard watchFD >= 0 else { throw CLIError(message: "Timed out waiting for \(path)") } let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)") let semaphore = DispatchSemaphore(value: 0) var found = false let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: watchFD, eventMask: [.write, .rename, .delete, .attrib, .extend, .link], queue: queue ) func checkPath() { guard !found else { return } if FileManager.default.fileExists(atPath: path) { found = true semaphore.signal() } } source.setEventHandler { checkPath() } source.setCancelHandler { Darwin.close(watchFD) } source.resume() queue.async { checkPath() } guard semaphore.wait(timeout: .now() + timeout) == .success else { source.cancel() throw CLIError(message: "Timed out waiting for \(path)") } source.cancel() } private static func existingWatchDirectory(forPath path: String) -> String? { let fileManager = FileManager.default var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true) while !candidate.path.isEmpty { var isDirectory: ObjCBool = false if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { return candidate.path } let parent = candidate.deletingLastPathComponent() if parent.path == candidate.path { break } candidate = parent } return nil } func sendV2(method: String, params: [String: Any] = [:]) throws -> [String: Any] { let request: [String: Any] = [ "id": UUID().uuidString, "method": method, "params": params ] guard JSONSerialization.isValidJSONObject(request) else { throw CLIError(message: "Failed to encode v2 request") } let requestData = try JSONSerialization.data(withJSONObject: request, options: []) guard let requestLine = String(data: requestData, encoding: .utf8) else { throw CLIError(message: "Failed to encode v2 request") } let raw = try send(command: requestLine) // The server may return plain-text errors (e.g., "ERROR: Access denied ...") // before the JSON protocol starts. Surface these directly instead of letting // JSONSerialization throw a confusing parse error. if raw.hasPrefix("ERROR:") { throw CLIError(message: raw) } guard let responseData = raw.data(using: .utf8) else { throw CLIError(message: "Invalid UTF-8 v2 response") } guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { throw CLIError(message: "Invalid v2 response: \(raw)") } if let ok = response["ok"] as? Bool, ok { return (response["result"] as? [String: Any]) ?? [:] } if let error = response["error"] as? [String: Any] { let code = (error["code"] as? String) ?? "error" let message = (error["message"] as? String) ?? "Unknown v2 error" throw CLIError(message: "\(code): \(message)") } throw CLIError(message: "v2 request failed") } } struct CLIProcessResult { let status: Int32 let stdout: String let stderr: String let timedOut: Bool } enum CLIProcessRunner { static func runProcess( executablePath: String, arguments: [String], stdinText: String? = nil, timeout: TimeInterval? = nil ) -> CLIProcessResult { let process = Process() process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe let stdinPipe: Pipe? if stdinText != nil { let pipe = Pipe() process.standardInput = pipe stdinPipe = pipe } else { stdinPipe = nil } let finished = DispatchSemaphore(value: 0) process.terminationHandler = { _ in finished.signal() } do { try process.run() } catch { return CLIProcessResult(status: 1, stdout: "", stderr: String(describing: error), timedOut: false) } if let stdinText, let stdinPipe { if let data = stdinText.data(using: .utf8) { stdinPipe.fileHandleForWriting.write(data) } stdinPipe.fileHandleForWriting.closeFile() } let timedOut: Bool if let timeout { switch finished.wait(timeout: .now() + timeout) { case .success: timedOut = false case .timedOut: timedOut = true terminate(process: process, finished: finished) } } else { finished.wait() timedOut = false } let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" var stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" if timedOut { let timeoutMessage = "process timed out" if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { stderr = timeoutMessage } else if !stderr.contains(timeoutMessage) { stderr += "\n\(timeoutMessage)" } } return CLIProcessResult( status: timedOut ? 124 : process.terminationStatus, stdout: stdout, stderr: stderr, timedOut: timedOut ) } private static func terminate(process: Process, finished: DispatchSemaphore) { guard process.isRunning else { return } process.terminate() if finished.wait(timeout: .now() + 0.5) == .success { return } if process.isRunning { kill(process.processIdentifier, SIGKILL) } _ = finished.wait(timeout: .now() + 0.5) } } struct CMUXCLI { let args: [String] private static let debugLastSocketHintPath = "/tmp/cmux-last-socket-path" private static func normalizedEnvValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed } private static func pathIsSocket(_ path: String) -> Bool { var st = stat() guard lstat(path, &st) == 0 else { return false } return (st.st_mode & S_IFMT) == S_IFSOCK } private static func debugSocketPathFromHintFile() -> String? { #if DEBUG guard let raw = try? String(contentsOfFile: debugLastSocketHintPath, encoding: .utf8) else { return nil } guard let hinted = normalizedEnvValue(raw), hinted.hasPrefix("/tmp/cmux-debug"), hinted.hasSuffix(".sock"), pathIsSocket(hinted) else { return nil } return hinted #else return nil #endif } private static func defaultSocketPath(environment: [String: String]) -> String { if let explicit = normalizedEnvValue(environment["CMUX_SOCKET_PATH"]) { return explicit } #if DEBUG if let hinted = debugSocketPathFromHintFile() { return hinted } return "/tmp/cmux-debug.sock" #else return "/tmp/cmux.sock" #endif } func run() throws { let processEnv = ProcessInfo.processInfo.environment let envSocketPath: String? = { for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { guard let raw = processEnv[key] else { continue } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } return nil }() var socketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath var socketPathSource: CLISocketPathSource if let envSocketPath { socketPathSource = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment } else { socketPathSource = .implicitDefault } var jsonOutput = false var idFormatArg: String? = nil var windowId: String? = nil var socketPasswordArg: String? = nil var index = 1 while index < args.count { let arg = args[index] if arg == "--socket" { guard index + 1 < args.count else { throw CLIError(message: "--socket requires a path") } socketPath = args[index + 1] socketPathSource = .explicitFlag index += 2 continue } if arg == "--json" { jsonOutput = true index += 1 continue } if arg == "--id-format" { guard index + 1 < args.count else { throw CLIError(message: "--id-format requires a value (refs|uuids|both)") } idFormatArg = args[index + 1] index += 2 continue } if arg == "--window" { guard index + 1 < args.count else { throw CLIError(message: "--window requires a window id") } windowId = args[index + 1] index += 2 continue } if arg == "--password" { guard index + 1 < args.count else { throw CLIError(message: "--password requires a value") } socketPasswordArg = args[index + 1] index += 2 continue } if arg == "-v" || arg == "--version" { print(versionSummary()) return } if arg == "-h" || arg == "--help" { print(usage()) return } break } guard index < args.count else { print(usage()) throw CLIError(message: "Missing command") } let command = args[index] let commandArgs = Array(args[(index + 1)...]) let cliTelemetry = CLISocketSentryTelemetry( command: command, commandArgs: commandArgs, socketPath: socketPath, processEnv: processEnv ) let resolvedSocketPath = CLISocketPathResolver.resolve( requestedPath: socketPath, source: socketPathSource, environment: processEnv ) if command == "version" { print(versionSummary()) return } if command == "remote-daemon-status" { try runRemoteDaemonStatus(commandArgs: commandArgs, jsonOutput: jsonOutput) return } // If the argument looks like a path (not a known command), open a workspace there. if looksLikePath(command) { try openPath(command, socketPath: resolvedSocketPath) return } // Check for --help/-h on subcommands before connecting to the socket, // so help text is available even when cmux is not running. if command != "__tmux-compat", command != "claude-teams", command != "codex", (commandArgs.contains("--help") || commandArgs.contains("-h")) { if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) { return } print("Unknown command '\(command)'. Run 'cmux help' to see available commands.") return } if command == "welcome" { printWelcome() return } if command == "shortcuts" { try runShortcuts( commandArgs: commandArgs, socketPath: resolvedSocketPath, explicitPassword: socketPasswordArg, jsonOutput: jsonOutput ) return } if command == "feedback" { try runFeedback( commandArgs: commandArgs, socketPath: resolvedSocketPath, explicitPassword: socketPasswordArg, jsonOutput: jsonOutput ) return } if command == "themes" { try runThemes( commandArgs: commandArgs, jsonOutput: jsonOutput ) return } if command == "claude-teams" { try runClaudeTeams( commandArgs: commandArgs, socketPath: resolvedSocketPath, explicitPassword: socketPasswordArg ) return } if command == "omo" { try runOMO( commandArgs: commandArgs, socketPath: resolvedSocketPath, explicitPassword: socketPasswordArg ) return } // Codex hooks management (no socket needed) if command == "codex" { let sub = commandArgs.first?.lowercased() ?? "help" if sub == "install-hooks" { try runCodexInstallHooks() return } else if sub == "uninstall-hooks" { try runCodexUninstallHooks() return } } // Codex hook handler: gracefully no-op when not inside cmux // (before socket connection, so it doesn't fail when no socket exists) if command == "codex-hook" { guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else { print("{}") return } } let client = SocketClient(path: resolvedSocketPath) if resolvedSocketPath != socketPath { cliTelemetry.breadcrumb( "socket.path.autodiscovered", data: [ "requested_path": socketPath, "resolved_path": resolvedSocketPath ] ) } cliTelemetry.breadcrumb( "socket.connect.attempt", data: [ "command": command, "path": resolvedSocketPath ] ) do { try client.connect() cliTelemetry.breadcrumb("socket.connect.success", data: ["path": resolvedSocketPath]) } catch { cliTelemetry.breadcrumb("socket.connect.failure", data: ["path": resolvedSocketPath]) cliTelemetry.captureError(stage: "socket_connect", error: error) throw error } defer { client.close() } try authenticateClientIfNeeded( client, explicitPassword: socketPasswordArg, socketPath: resolvedSocketPath ) let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // If the user explicitly targets a window, focus it first so commands route correctly. if let windowId { let normalizedWindow = try normalizeWindowHandle(windowId, client: client) ?? windowId _ = try client.sendV2(method: "window.focus", params: ["window_id": normalizedWindow]) } switch command { case "ping": let response = try sendV1Command("ping", client: client) print(response) case "capabilities": let response = try client.sendV2(method: "system.capabilities") print(jsonString(formatIDs(response, mode: idFormat))) case "identify": var params: [String: Any] = [:] let includeCaller = !hasFlag(commandArgs, name: "--no-caller") if includeCaller { let idWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = idWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = optionValue(commandArgs, name: "--surface") ?? (idWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) if workspaceArg != nil || surfaceArg != nil { let workspaceId = try normalizeWorkspaceHandle( workspaceArg, client: client, allowCurrent: surfaceArg != nil ) var caller: [String: Any] = [:] if let workspaceId { caller["workspace_id"] = workspaceId } if surfaceArg != nil { guard let surfaceId = try normalizeSurfaceHandle( surfaceArg, client: client, workspaceHandle: workspaceId ) else { throw CLIError(message: "Invalid surface handle") } caller["surface_id"] = surfaceId } if !caller.isEmpty { params["caller"] = caller } } } let response = try client.sendV2(method: "system.identify", params: params) print(jsonString(formatIDs(response, mode: idFormat))) case "list-windows": let response = try sendV1Command("list_windows", client: client) if jsonOutput { let windows = parseWindows(response) let payload = windows.map { item -> [String: Any] in var dict: [String: Any] = [ "index": item.index, "id": item.id, "key": item.key, "workspace_count": item.workspaceCount, ] dict["selected_workspace_id"] = item.selectedWorkspaceId ?? NSNull() return dict } print(jsonString(payload)) } else { print(response) } case "current-window": let response = try sendV1Command("current_window", client: client) if jsonOutput { print(jsonString(["window_id": response])) } else { print(response) } case "new-window": let response = try sendV1Command("new_window", client: client) print(response) case "focus-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "focus-window requires --window") } let response = try sendV1Command("focus_window \(target)", client: client) print(response) case "close-window": guard let target = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "close-window requires --window") } let response = try sendV1Command("close_window \(target)", client: client) print(response) case "move-workspace-to-window": guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "move-workspace-to-window requires --workspace") } guard let windowRaw = optionValue(commandArgs, name: "--window") else { throw CLIError(message: "move-workspace-to-window requires --window") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) if let wsId { params["workspace_id"] = wsId } let winId = try normalizeWindowHandle(windowRaw, client: client) if let winId { params["window_id"] = winId } let payload = try client.sendV2(method: "workspace.move_to_window", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace", "window"])) case "move-surface": try runMoveSurface(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "reorder-surface": try runReorderSurface(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "reorder-workspace": try runReorderWorkspace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "workspace-action": try runWorkspaceAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) case "tab-action": try runTabAction(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) case "rename-tab": try runRenameTab(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId) case "list-workspaces": let payload = try client.sendV2(method: "workspace.list") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] if workspaces.isEmpty { print("No workspaces") } else { for ws in workspaces { let selected = (ws["selected"] as? Bool) == true let handle = textHandle(ws, idFormat: idFormat) let title = (ws["title"] as? String) ?? "" let remoteTag: String = { guard let remote = ws["remote"] as? [String: Any], (remote["enabled"] as? Bool) == true else { return "" } let state = (remote["state"] as? String) ?? "unknown" return " [ssh:\(state)]" }() let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" let titlePart = title.isEmpty ? "" : " \(title)" print("\(prefix)\(handle)\(titlePart)\(remoteTag)\(selTag)") } } } case "ssh": try runSSH(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "ssh-session-end": try runSSHSessionEnd(commandArgs: commandArgs, client: client) case "new-workspace": let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let (cwdOpt, rem1) = parseOption(rem0, name: "--cwd") let (nameOpt, remaining) = parseOption(rem1, name: "--name") if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name , --command <text>, --cwd <path>") } var params: [String: Any] = [:] if let cwdOpt { let resolved = resolvePath(cwdOpt) params["cwd"] = resolved } if let nameOpt { params["title"] = nameOpt } let response = try client.sendV2(method: "workspace.create", params: params) let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" print("OK \(wsId)") if let commandText = commandOpt, !wsId.isEmpty { let text = unescapeSendText(commandText + "\\n") let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] _ = try client.sendV2(method: "surface.send_text", params: sendParams) } case "new-split": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (panelArg, rem1) = parseOption(rem0, name: "--panel") let (sfArg, rem2) = parseOption(rem1, name: "--surface") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceRaw = sfArg ?? panelArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) guard let direction = rem2.first else { throw CLIError(message: "new-split requires a direction") } var params: [String: Any] = ["direction": direction] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.split", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "list-panes": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "pane.list", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let panes = payload["panes"] as? [[String: Any]] ?? [] if panes.isEmpty { print("No panes") } else { for pane in panes { let focused = (pane["focused"] as? Bool) == true let handle = textHandle(pane, idFormat: idFormat) let count = pane["surface_count"] as? Int ?? 0 let prefix = focused ? "* " : " " let focusTag = focused ? " [focused]" : "" print("\(prefix)\(handle) [\(count) surface\(count == 1 ? "" : "s")]\(focusTag)") } } } case "list-pane-surfaces": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) let paneRaw = optionValue(commandArgs, name: "--pane") var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) if let paneId { params["pane_id"] = paneId } let payload = try client.sendV2(method: "pane.surfaces", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] if surfaces.isEmpty { print("No surfaces in pane") } else { for surface in surfaces { let selected = (surface["selected"] as? Bool) == true let handle = textHandle(surface, idFormat: idFormat) let title = (surface["title"] as? String) ?? "" let prefix = selected ? "* " : " " let selTag = selected ? " [selected]" : "" print("\(prefix)\(handle) \(title)\(selTag)") } } } case "tree": try runTreeCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "focus-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else { throw CLIError(message: "focus-pane requires --pane <id|ref>") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) if let paneId { params["pane_id"] = paneId } let payload = try client.sendV2(method: "pane.focus", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane", "workspace"])) case "new-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) let type = optionValue(commandArgs, name: "--type") let direction = optionValue(commandArgs, name: "--direction") ?? "right" let url = optionValue(commandArgs, name: "--url") var params: [String: Any] = ["direction": direction] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } if let type { params["type"] = type } if let url { params["url"] = url } let payload = try client.sendV2(method: "pane.create", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "new-surface": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) let type = optionValue(commandArgs, name: "--type") let paneRaw = optionValue(commandArgs, name: "--pane") let url = optionValue(commandArgs, name: "--url") var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let paneId = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: wsId) if let paneId { params["pane_id"] = paneId } if let type { params["type"] = type } if let url { params["url"] = url } let payload = try client.sendV2(method: "surface.create", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "workspace"])) case "close-surface": let csWsFlag = optionValue(commandArgs, name: "--workspace") let workspaceArg = csWsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") ?? (csWsFlag == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.close", params: params) if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId, let closedSurfaceId = (payload["surface_id"] as? String) ?? sfId { try? tmuxPruneCompatSurfaceState( workspaceId: closedWorkspaceId, surfaceId: closedSurfaceId, client: client ) } printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "drag-surface-to-split": let (surfaceArg, rem0) = parseOption(commandArgs, name: "--surface") let (panelArg, rem1) = parseOption(rem0, name: "--panel") let surface = surfaceArg ?? panelArg guard let surface else { throw CLIError(message: "drag-surface-to-split requires --surface <id|index>") } guard let direction = rem1.first else { throw CLIError(message: "drag-surface-to-split requires a direction") } let response = try sendV1Command("drag_surface_to_split \(surface) \(direction)", client: client) print(response) case "refresh-surfaces": let response = try sendV1Command("refresh_surfaces", client: client) print(response) case "surface-health": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "surface.health", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] if surfaces.isEmpty { print("No surfaces") } else { for surface in surfaces { let handle = textHandle(surface, idFormat: idFormat) let sType = (surface["type"] as? String) ?? "" let inWindow = surface["in_window"] let inWindowStr: String if let b = inWindow as? Bool { inWindowStr = " in_window=\(b)" } else { inWindowStr = "" } print("\(handle) type=\(sType)\(inWindowStr)") } } } case "debug-terminals": let unexpected = commandArgs.filter { $0 != "--" } if let extra = unexpected.first { throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") } let payload = try client.sendV2(method: "debug.terminals") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { print(formatDebugTerminalsPayload(payload, idFormat: idFormat)) } case "trigger-flash": let tfWsFlag = optionValue(commandArgs, name: "--workspace") let explicitWorkspaceArg = tfWsFlag let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil let callerWorkspaceArg = preferTTYFallback ? nil : (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") ?? optionValue(commandArgs, name: "--panel") let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg var params: [String: Any] = [:] let wsId = try { if explicitWorkspaceArg != nil { return try normalizeWorkspaceHandle(workspaceArg, client: client) } return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) }() if let wsId { params["workspace_id"] = wsId } let sfId = try { if explicitSurfaceArg != nil { return try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) } guard let wsId else { return nil } return try resolveSurfaceIdAllowingFallback( surfaceArg, workspaceId: wsId, client: client ) }() if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.trigger_flash", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "list-panels": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "surface.list", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] if surfaces.isEmpty { print("No surfaces") } else { for surface in surfaces { let focused = (surface["focused"] as? Bool) == true let handle = textHandle(surface, idFormat: idFormat) let sType = (surface["type"] as? String) ?? "" let title = (surface["title"] as? String) ?? "" let prefix = focused ? "* " : " " let focusTag = focused ? " [focused]" : "" let titlePart = title.isEmpty ? "" : " \"\(title)\"" print("\(prefix)\(handle) \(sType)\(focusTag)\(titlePart)") } } } case "focus-panel": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId) guard let panelRaw = optionValue(commandArgs, name: "--panel") else { throw CLIError(message: "focus-panel requires --panel") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(panelRaw, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.focus", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "close-workspace": guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "close-workspace requires --workspace") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "workspace.close", params: params) if let closedWorkspaceId = (payload["workspace_id"] as? String) ?? wsId { try? tmuxPruneCompatWorkspaceState(workspaceId: closedWorkspaceId) } printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "select-workspace": guard let workspaceRaw = optionValue(commandArgs, name: "--workspace") else { throw CLIError(message: "select-workspace requires --workspace") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceRaw, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "workspace.select", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "rename-workspace", "rename-window": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw CLIError(message: "\(command) requires a title") } let wsId = try resolveWorkspaceId(workspaceArg, client: client) let params: [String: Any] = ["title": title, "workspace_id": wsId] let payload = try client.sendV2(method: "workspace.rename", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "current-workspace": let response = try sendV1Command("current_workspace", client: client) if jsonOutput { print(jsonString(["workspace_id": response])) } else { print(response) } case "read-screen": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") let (linesArg, rem2) = parseOption(rem1, name: "--lines") let trailing = rem2.filter { $0 != "--scrollback" } if !trailing.isEmpty { throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))") } let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let includeScrollback = rem2.contains("--scrollback") if includeScrollback { params["scrollback"] = true } if let linesArg { guard let lineCount = Int(linesArg), lineCount > 0 else { throw CLIError(message: "--lines must be greater than 0") } params["lines"] = lineCount params["scrollback"] = true } let payload = try client.sendV2(method: "surface.read_text", params: params) if jsonOutput { print(jsonString(payload)) } else { print((payload["text"] as? String) ?? "") } case "send": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") guard !rawText.isEmpty else { throw CLIError(message: "send requires text") } let text = unescapeSendText(rawText) var params: [String: Any] = ["text": text] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_text", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-key": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) let keyArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 guard let key = keyArgs.first else { throw CLIError(message: "send-key requires a key") } var params: [String: Any] = ["key": key] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_key", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-panel": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (panelArg, rem1) = parseOption(rem0, name: "--panel") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) guard let panelArg else { throw CLIError(message: "send-panel requires --panel") } let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") guard !rawText.isEmpty else { throw CLIError(message: "send-panel requires text") } let text = unescapeSendText(rawText) var params: [String: Any] = ["text": text] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_text", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "send-key-panel": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (panelArg, rem1) = parseOption(rem0, name: "--panel") let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) guard let panelArg else { throw CLIError(message: "send-key-panel requires --panel") } let skpArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 let key = skpArgs.first ?? "" guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") } var params: [String: Any] = ["key": key] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(panelArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_key", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "notify": let title = optionValue(commandArgs, name: "--title") ?? "Notification" let subtitle = optionValue(commandArgs, name: "--subtitle") ?? "" let body = optionValue(commandArgs, name: "--body") ?? "" let explicitWorkspaceArg = optionValue(commandArgs, name: "--workspace") let preferTTYFallback = windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil let callerWorkspaceArg = preferTTYFallback ? nil : (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg let explicitSurfaceArg = optionValue(commandArgs, name: "--surface") let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg let targetWorkspace = try { if explicitWorkspaceArg != nil { return try resolveWorkspaceId(workspaceArg, client: client) } return try resolveWorkspaceIdAllowingFallback(workspaceArg, client: client) }() let targetSurface = try { if explicitSurfaceArg != nil { return try resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: client) } return try resolveSurfaceIdAllowingFallback( surfaceArg, workspaceId: targetWorkspace, client: client ) }() let payload = "\(title)|\(subtitle)|\(body)" let response = try sendV1Command("notify_target \(targetWorkspace) \(targetSurface) \(payload)", client: client) print(response) case "list-notifications": let response = try sendV1Command("list_notifications", client: client) if jsonOutput { let notifications = parseNotifications(response) let payload = notifications.map { item in var dict: [String: Any] = [ "id": item.id, "workspace_id": item.workspaceId, "is_read": item.isRead, "title": item.title, "subtitle": item.subtitle, "body": item.body ] dict["surface_id"] = item.surfaceId ?? NSNull() return dict } print(jsonString(payload)) } else { print(response) } case "clear-notifications": var socketCmd = "clear_notifications" if let wsFlag = optionValue(commandArgs, name: "--workspace") { let wsId = try resolveWorkspaceId(wsFlag, client: client) socketCmd += " --tab=\(wsId)" } else if windowId == nil, let envWs = ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"], let wsId = try? resolveWorkspaceId(envWs, client: client) { socketCmd += " --tab=\(wsId)" } let response = try sendV1Command(socketCmd, client: client) print(response) case "set-status": let response = try forwardSidebarMetadataCommand( "set_status", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "clear-status": let response = try forwardSidebarMetadataCommand( "clear_status", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "list-status": let response = try forwardSidebarMetadataCommand( "list_status", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "set-progress": let response = try forwardSidebarMetadataCommand( "set_progress", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "clear-progress": let response = try forwardSidebarMetadataCommand( "clear_progress", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "log": let response = try forwardSidebarMetadataCommand( "log", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "clear-log": let response = try forwardSidebarMetadataCommand( "clear_log", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "list-log": let response = try forwardSidebarMetadataCommand( "list_log", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "sidebar-state": let response = try forwardSidebarMetadataCommand( "sidebar_state", commandArgs: commandArgs, client: client, windowOverride: windowId ) print(response) case "claude-hook": cliTelemetry.breadcrumb("claude-hook.dispatch") do { try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) cliTelemetry.breadcrumb("claude-hook.completed") } catch { cliTelemetry.breadcrumb("claude-hook.failure") cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error) throw error } case "codex-hook": cliTelemetry.breadcrumb("codex-hook.dispatch") do { try runCodexHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) cliTelemetry.breadcrumb("codex-hook.completed") } catch { cliTelemetry.breadcrumb("codex-hook.failure") cliTelemetry.captureError(stage: "codex_hook_dispatch", error: error) throw error } case "set-app-focus": guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } let response = try sendV1Command("set_app_focus \(value)", client: client) print(response) case "simulate-app-active": let response = try sendV1Command("simulate_app_active", client: client) print(response) case "__tmux-compat": try runClaudeTeamsTmuxCompat( commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId ) case "capture-pane", "resize-pane", "pipe-pane", "wait-for", "swap-pane", "break-pane", "join-pane", "last-window", "last-pane", "next-window", "previous-window", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message": try runTmuxCompatCommand( command: command, commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId ) case "help": print(usage()) // Browser commands case "browser": try runBrowserCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) // Legacy aliases shimmed onto the v2 browser command surface. case "open-browser": try runBrowserCommand(commandArgs: ["open"] + commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "navigate": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["navigate"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "browser-back": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["back"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "browser-forward": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["forward"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "browser-reload": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["reload"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "get-url": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["get-url"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "focus-webview": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["focus-webview"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) case "is-webview-focused": let bridged = replaceToken(commandArgs, from: "--panel", to: "--surface") try runBrowserCommand(commandArgs: ["is-webview-focused"] + bridged, client: client, jsonOutput: jsonOutput, idFormat: idFormat) // Markdown commands case "markdown": try runMarkdownCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat) default: print(usage()) throw CLIError(message: "Unknown command: \(command)") } } private func resolvePath(_ path: String) -> String { let expanded = NSString(string: path).expandingTildeInPath if expanded.hasPrefix("/") { return expanded } let cwd = FileManager.default.currentDirectoryPath return (cwd as NSString).appendingPathComponent(expanded) } private func sanitizedFilenameComponent(_ raw: String) -> String { let sanitized = raw.replacingOccurrences( of: #"[^\p{L}\p{N}._-]+"#, with: "-", options: .regularExpression ) let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-.")) return trimmed.isEmpty ? "item" : trimmed } private func bestEffortPruneTemporaryFiles( in directoryURL: URL, keepingMostRecent maxCount: Int = 50, maxAge: TimeInterval = 24 * 60 * 60 ) { guard let entries = try? FileManager.default.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], options: [.skipsHiddenFiles] ) else { return } let now = Date() let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), values.isRegularFile == true else { return nil } return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) }.sorted { $0.date > $1.date } for (index, entry) in datedEntries.enumerated() { if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { try? FileManager.default.removeItem(at: entry.url) } } } // MARK: - Markdown Commands private func runMarkdownCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { var args = commandArgs // Parse routing flags let (workspaceOpt, argsAfterWorkspace) = parseOption(args, name: "--workspace") let (windowOpt, argsAfterWindow) = parseOption(argsAfterWorkspace, name: "--window") let (surfaceOpt, argsAfterSurface) = parseOption(argsAfterWindow, name: "--surface") let (directionOpt, argsAfterDirection) = parseOption(argsAfterSurface, name: "--direction") args = argsAfterDirection // Determine subcommand. Explicit "open" is supported, otherwise treat // a single positional argument as shorthand path. let subArgs: [String] if let first = args.first, first.lowercased() == "open" { subArgs = Array(args.dropFirst()) } else if args.count == 1, let first = args.first, !first.hasPrefix("-") { subArgs = [first] } else { // Allow path-like first tokens (e.g. plan.md) with trailing args // so we can surface specific trailing-arg/flag errors below. if let first = args.first, first.hasPrefix("-") { throw CLIError( message: "markdown open: unknown flag '\(first)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--direction right|down|left|up]" ) } else if let first = args.first, looksLikePath(first) || first.contains(".") { subArgs = args } else if let first = args.first { throw CLIError(message: "Unknown markdown subcommand: \(first). Usage: cmux markdown open <path>") } else { subArgs = [] } } guard let rawPath = subArgs.first, !rawPath.isEmpty else { throw CLIError(message: "markdown open requires a file path. Usage: cmux markdown open <path>") } let trailingArgs = Array(subArgs.dropFirst()) if let unknownFlag = trailingArgs.first(where: { $0.hasPrefix("-") }) { throw CLIError( message: "markdown open: unknown flag '\(unknownFlag)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--direction right|down|left|up]" ) } if let extraArg = trailingArgs.first { throw CLIError( message: "markdown open: unexpected argument '\(extraArg)'. Usage: cmux markdown open <path> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--direction right|down|left|up]" ) } let absolutePath = resolvePath(rawPath) // Build params let direction = directionOpt ?? "right" var params: [String: Any] = ["path": absolutePath, "direction": direction] if let surfaceRaw = surfaceOpt { if let surface = try normalizeSurfaceHandle(surfaceRaw, client: client) { params["surface_id"] = surface } } let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) if let workspaceRaw { if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { params["workspace_id"] = workspace } } if let windowRaw = windowOpt { if let window = try normalizeWindowHandle(windowRaw, client: client) { params["window_id"] = window } } let payload = try client.sendV2(method: "markdown.open", params: params) if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" let filePath = (payload["path"] as? String) ?? absolutePath print("OK surface=\(surfaceText) pane=\(paneText) path=\(filePath)") } } /// Returns true if the argument looks like a filesystem path rather than a CLI command. private func looksLikePath(_ arg: String) -> Bool { if arg == "." || arg == ".." { return true } if arg.hasPrefix("/") || arg.hasPrefix("./") || arg.hasPrefix("../") || arg.hasPrefix("~") { return true } if arg.contains("/") { return true } return false } /// Open a path in cmux by creating a new workspace with the given directory. /// Launches the app if it isn't already running. private func openPath(_ path: String, socketPath: String) throws { let resolved = resolvePath(path) var isDir: ObjCBool = false let exists = FileManager.default.fileExists(atPath: resolved, isDirectory: &isDir) let directory: String if exists && isDir.boolValue { directory = resolved } else if exists { // It's a file; use its parent directory directory = (resolved as NSString).deletingLastPathComponent } else { throw CLIError(message: "Path does not exist: \(resolved)") } // Try connecting to the socket. If it fails, launch the app and retry. let client = SocketClient(path: socketPath) if (try? client.connect()) == nil { client.close() try launchApp() let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) defer { launchedClient.close() } let params: [String: Any] = ["cwd": directory] let response = try launchedClient.sendV2(method: "workspace.create", params: params) let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" if !wsRef.isEmpty { print("OK \(wsRef)") } try activateApp() return } defer { client.close() } let params: [String: Any] = ["cwd": directory] let response = try client.sendV2(method: "workspace.create", params: params) let wsRef = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" if !wsRef.isEmpty { print("OK \(wsRef)") } // Bring the app to front try activateApp() } private func runFeedback( commandArgs: [String], socketPath: String, explicitPassword: String?, jsonOutput: Bool ) throws { let (emailOpt, rem0) = parseOption(commandArgs, name: "--email") let (bodyOpt, rem1) = parseOption(rem0, name: "--body") let (imagePaths, rem2) = parseRepeatedOption(rem1, name: "--image") let remaining = rem2.filter { $0 != "--" } if let unknown = remaining.first { throw CLIError(message: "feedback: unknown flag '\(unknown)'. Known flags: --email <email>, --body <text>, --image <path>") } let client = try connectClient( socketPath: socketPath, explicitPassword: explicitPassword, launchIfNeeded: true ) defer { client.close() } if emailOpt == nil && bodyOpt == nil && imagePaths.isEmpty { var params: [String: Any] = [:] let env = ProcessInfo.processInfo.environment if let workspaceId = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceId.isEmpty { params["workspace_id"] = workspaceId params["activate"] = false } else { params["activate"] = true } let response = try client.sendV2(method: "feedback.open", params: params) if jsonOutput { print(jsonString(response)) } else { print("OK") } return } guard let email = emailOpt?.trimmingCharacters(in: .whitespacesAndNewlines), email.isEmpty == false else { throw CLIError(message: "feedback requires --email <email> when sending feedback") } guard let body = bodyOpt, body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw CLIError(message: "feedback requires --body <text> when sending feedback") } let resolvedImages = imagePaths.map(resolvePath) let response = try client.sendV2(method: "feedback.submit", params: [ "email": email, "body": body, "image_paths": resolvedImages, ]) if jsonOutput { print(jsonString(response)) } else { print("OK") } } private func runShortcuts( commandArgs: [String], socketPath: String, explicitPassword: String?, jsonOutput: Bool ) throws { let remaining = commandArgs.filter { $0 != "--" } if let unknown = remaining.first { throw CLIError(message: "shortcuts: unknown flag '\(unknown)'") } let client = try connectClient( socketPath: socketPath, explicitPassword: explicitPassword, launchIfNeeded: true ) defer { client.close() } let response = try client.sendV2(method: "settings.open", params: [ "target": "keyboardShortcuts", "activate": true, ]) if jsonOutput { print(jsonString(response)) } else { print("OK") } } private func connectClient( socketPath: String, explicitPassword: String?, launchIfNeeded: Bool ) throws -> SocketClient { let client = SocketClient(path: socketPath) if launchIfNeeded && (try? client.connect()) == nil { client.close() try launchApp() let launchedClient = try SocketClient.waitForConnectableSocket(path: socketPath, timeout: 10) try authenticateClientIfNeeded( launchedClient, explicitPassword: explicitPassword, socketPath: socketPath ) return launchedClient } try client.connect() try authenticateClientIfNeeded( client, explicitPassword: explicitPassword, socketPath: socketPath ) return client } private func authenticateClientIfNeeded( _ client: SocketClient, explicitPassword: String?, socketPath: String ) throws { if let socketPassword = SocketPasswordResolver.resolve( explicit: explicitPassword, socketPath: socketPath ) { let authResponse = try client.send(command: "auth \(socketPassword)") if authResponse.hasPrefix("ERROR:"), !authResponse.contains("Unknown command 'auth'") { throw CLIError(message: authResponse) } } } private func launchApp() throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") process.arguments = ["-a", "cmux"] try process.run() process.waitUntilExit() } private func activateApp() throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/open") process.arguments = ["-a", "cmux"] try process.run() process.waitUntilExit() } private func resolvedIDFormat(jsonOutput: Bool, raw: String?) throws -> CLIIDFormat { _ = jsonOutput if let parsed = try CLIIDFormat.parse(raw) { return parsed } return .refs } private func sendV1Command(_ command: String, client: SocketClient) throws -> String { let response = try client.send(command: command) if response.hasPrefix("ERROR:") { throw CLIError(message: response) } return response } private func formatIDs(_ object: Any, mode: CLIIDFormat) -> Any { switch object { case let dict as [String: Any]: var out: [String: Any] = [:] for (k, v) in dict { out[k] = formatIDs(v, mode: mode) } switch mode { case .both: break case .refs: if out["ref"] != nil && out["id"] != nil { out.removeValue(forKey: "id") } let keys = Array(out.keys) for key in keys where key.hasSuffix("_id") { let prefix = String(key.dropLast(3)) if out["\(prefix)_ref"] != nil { out.removeValue(forKey: key) } } for key in keys where key.hasSuffix("_ids") { let prefix = String(key.dropLast(4)) if out["\(prefix)_refs"] != nil { out.removeValue(forKey: key) } } case .uuids: if out["id"] != nil && out["ref"] != nil { out.removeValue(forKey: "ref") } let keys = Array(out.keys) for key in keys where key.hasSuffix("_ref") { let prefix = String(key.dropLast(4)) if out["\(prefix)_id"] != nil { out.removeValue(forKey: key) } } for key in keys where key.hasSuffix("_refs") { let prefix = String(key.dropLast(5)) if out["\(prefix)_ids"] != nil { out.removeValue(forKey: key) } } } return out case let array as [Any]: return array.map { formatIDs($0, mode: mode) } default: return object } } private func intFromAny(_ value: Any?) -> Int? { if let i = value as? Int { return i } if let n = value as? NSNumber { return n.intValue } if let s = value as? String { return Int(s) } return nil } private func doubleFromAny(_ value: Any?) -> Double? { if let d = value as? Double { return d } if let f = value as? Float { return Double(f) } if let n = value as? NSNumber { return n.doubleValue } if let s = value as? String { return Double(s) } return nil } private func parseBoolString(_ raw: String) -> Bool? { switch raw.lowercased() { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: return nil } } private func parsePositiveInt(_ raw: String?, label: String) throws -> Int? { guard let raw else { return nil } guard let value = Int(raw) else { throw CLIError(message: "\(label) must be an integer") } return value } private func isHandleRef(_ value: String) -> Bool { let pieces = value.split(separator: ":", omittingEmptySubsequences: false) guard pieces.count == 2 else { return false } let kind = String(pieces[0]).lowercased() guard ["window", "workspace", "pane", "surface"].contains(kind) else { return false } return Int(String(pieces[1])) != nil } private func normalizeWindowHandle(_ raw: String?, client: SocketClient, allowCurrent: Bool = false) throws -> String? { guard let raw else { if !allowCurrent { return nil } let current = try client.sendV2(method: "window.current") return (current["window_ref"] as? String) ?? (current["window_id"] as? String) } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if isUUID(trimmed) || isHandleRef(trimmed) { return trimmed } guard let wantedIndex = Int(trimmed) else { throw CLIError(message: "Invalid window handle: \(trimmed) (expected UUID, ref like window:1, or index)") } let listed = try client.sendV2(method: "window.list") let windows = listed["windows"] as? [[String: Any]] ?? [] for item in windows where intFromAny(item["index"]) == wantedIndex { return (item["ref"] as? String) ?? (item["id"] as? String) } throw CLIError(message: "Window index not found") } private func normalizeWorkspaceHandle( _ raw: String?, client: SocketClient, windowHandle: String? = nil, allowCurrent: Bool = false ) throws -> String? { guard let raw else { if !allowCurrent { return nil } let current = try client.sendV2(method: "workspace.current") return (current["workspace_ref"] as? String) ?? (current["workspace_id"] as? String) } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if isUUID(trimmed) || isHandleRef(trimmed) { return trimmed } guard let wantedIndex = Int(trimmed) else { throw CLIError(message: "Invalid workspace handle: \(trimmed) (expected UUID, ref like workspace:1, or index)") } var params: [String: Any] = [:] if let windowHandle { params["window_id"] = windowHandle } let listed = try client.sendV2(method: "workspace.list", params: params) let items = listed["workspaces"] as? [[String: Any]] ?? [] for item in items where intFromAny(item["index"]) == wantedIndex { return (item["ref"] as? String) ?? (item["id"] as? String) } throw CLIError(message: "Workspace index not found") } private func normalizePaneHandle( _ raw: String?, client: SocketClient, workspaceHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { guard let raw else { if !allowFocused { return nil } let ident = try client.sendV2(method: "system.identify") let focused = ident["focused"] as? [String: Any] ?? [:] return (focused["pane_ref"] as? String) ?? (focused["pane_id"] as? String) } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if isUUID(trimmed) || isHandleRef(trimmed) { return trimmed } guard let wantedIndex = Int(trimmed) else { throw CLIError(message: "Invalid pane handle: \(trimmed) (expected UUID, ref like pane:1, or index)") } var params: [String: Any] = [:] if let workspaceHandle { params["workspace_id"] = workspaceHandle } let listed = try client.sendV2(method: "pane.list", params: params) let items = listed["panes"] as? [[String: Any]] ?? [] for item in items where intFromAny(item["index"]) == wantedIndex { return (item["ref"] as? String) ?? (item["id"] as? String) } throw CLIError(message: "Pane index not found") } private func normalizeSurfaceHandle( _ raw: String?, client: SocketClient, workspaceHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { guard let raw else { if !allowFocused { return nil } let ident = try client.sendV2(method: "system.identify") let focused = ident["focused"] as? [String: Any] ?? [:] return (focused["surface_ref"] as? String) ?? (focused["surface_id"] as? String) } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } if isUUID(trimmed) || isHandleRef(trimmed) { return trimmed } guard let wantedIndex = Int(trimmed) else { throw CLIError(message: "Invalid surface handle: \(trimmed) (expected UUID, ref like surface:1, or index)") } var params: [String: Any] = [:] if let workspaceHandle { params["workspace_id"] = workspaceHandle } let listed = try client.sendV2(method: "surface.list", params: params) let items = listed["surfaces"] as? [[String: Any]] ?? [] for item in items where intFromAny(item["index"]) == wantedIndex { return (item["ref"] as? String) ?? (item["id"] as? String) } throw CLIError(message: "Surface index not found") } private func canonicalSurfaceHandleFromTabInput(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false) guard pieces.count == 2, String(pieces[0]).lowercased() == "tab", let ordinal = Int(String(pieces[1])) else { return trimmed } return "surface:\(ordinal)" } private func normalizeTabHandle( _ raw: String?, client: SocketClient, workspaceHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { guard let raw else { return try normalizeSurfaceHandle( nil, client: client, workspaceHandle: workspaceHandle, allowFocused: allowFocused ) } let canonical = canonicalSurfaceHandleFromTabInput(raw) return try normalizeSurfaceHandle( canonical, client: client, workspaceHandle: workspaceHandle, allowFocused: false ) } private func displayTabHandle(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let pieces = trimmed.split(separator: ":", omittingEmptySubsequences: false) guard pieces.count == 2, String(pieces[0]).lowercased() == "surface", let ordinal = Int(String(pieces[1])) else { return trimmed } return "tab:\(ordinal)" } private func formatHandle(_ payload: [String: Any], kind: String, idFormat: CLIIDFormat) -> String? { let id = payload["\(kind)_id"] as? String let ref = payload["\(kind)_ref"] as? String switch idFormat { case .refs: return ref ?? id case .uuids: return id ?? ref case .both: if let ref, let id { return "\(ref) (\(id))" } return ref ?? id } } private func formatTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? { let id = (payload["tab_id"] as? String) ?? (payload["surface_id"] as? String) let refRaw = (payload["tab_ref"] as? String) ?? (payload["surface_ref"] as? String) let ref = displayTabHandle(refRaw) switch idFormat { case .refs: return ref ?? id case .uuids: return id ?? ref case .both: if let ref, let id { return "\(ref) (\(id))" } return ref ?? id } } private func formatCreatedTabHandle(_ payload: [String: Any], idFormat: CLIIDFormat) -> String? { let id = (payload["created_tab_id"] as? String) ?? (payload["created_surface_id"] as? String) let refRaw = (payload["created_tab_ref"] as? String) ?? (payload["created_surface_ref"] as? String) let ref = displayTabHandle(refRaw) switch idFormat { case .refs: return ref ?? id case .uuids: return id ?? ref case .both: if let ref, let id { return "\(ref) (\(id))" } return ref ?? id } } private func printV2Payload( _ payload: [String: Any], jsonOutput: Bool, idFormat: CLIIDFormat, fallbackText: String ) { if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { print(fallbackText) } } private func debugString(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil } if let string = value as? String { return string } if let number = value as? NSNumber { return number.stringValue } return String(describing: value) } private func debugBool(_ value: Any?) -> Bool? { if let bool = value as? Bool { return bool } if let number = value as? NSNumber { return number.boolValue } if let string = value as? String { return parseBoolString(string) } return nil } private func debugFlag(_ value: Any?) -> String { guard let bool = debugBool(value) else { return "nil" } return bool ? "1" : "0" } private func formatDebugRect(_ value: Any?) -> String? { guard let rect = value as? [String: Any], let x = doubleFromAny(rect["x"]), let y = doubleFromAny(rect["y"]), let width = doubleFromAny(rect["width"]), let height = doubleFromAny(rect["height"]) else { return nil } return String(format: "{%.1f,%.1f %.1fx%.1f}", x, y, width, height) } private func formatDebugPorts(_ value: Any?) -> String { guard let array = value as? [Any], !array.isEmpty else { return "[]" } let ports = array .compactMap { intFromAny($0) } .map(String.init) return ports.isEmpty ? "[]" : ports.joined(separator: ",") } private func formatDebugList(_ value: Any?) -> String? { guard let array = value as? [Any], !array.isEmpty else { return nil } let items = array.compactMap { item -> String? in if let string = item as? String { return string } return debugString(item) } guard !items.isEmpty else { return nil } return items.joined(separator: ">") } private func formatDebugAge(_ value: Any?) -> String? { guard let seconds = doubleFromAny(value) else { return nil } return String(format: "%.3fs", seconds) } private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { let terminals = payload["terminals"] as? [[String: Any]] ?? [] guard !terminals.isEmpty else { return "No terminal surfaces" } return terminals.map { item in let index = intFromAny(item["index"]) ?? 0 let surface = formatHandle(item, kind: "surface", idFormat: idFormat) ?? "?" let window = formatHandle(item, kind: "window", idFormat: idFormat) ?? "nil" let workspace = formatHandle(item, kind: "workspace", idFormat: idFormat) ?? "nil" let pane = formatHandle(item, kind: "pane", idFormat: idFormat) ?? "nil" let bonsplitTab = debugString(item["bonsplit_tab_id"]) ?? "nil" let lastKnownWorkspace = debugString(item["last_known_workspace_ref"]) ?? debugString(item["last_known_workspace_id"]) ?? "nil" let titleSuffix: String = { guard let title = debugString(item["surface_title"]), !title.isEmpty else { return "" } let escaped = title.replacingOccurrences(of: "\"", with: "\\\"") return " \"\(escaped)\"" }() let branchLabel: String = { guard let branch = debugString(item["git_branch"]), !branch.isEmpty else { return "nil" } return debugBool(item["git_dirty"]) == true ? "\(branch)*" : branch }() let teardownLabel: String = { guard debugBool(item["teardown_requested"]) == true else { return "nil" } let reason = debugString(item["teardown_requested_reason"]) ?? "requested" let age = formatDebugAge(item["teardown_requested_age_seconds"]) ?? "unknown" return "\(reason)@\(age)" }() let portalHostLabel: String = { let hostId = debugString(item["portal_host_id"]) ?? "nil" let area = doubleFromAny(item["portal_host_area"]).map { String(format: "%.1f", $0) } ?? "nil" let inWindow = debugFlag(item["portal_host_in_window"]) return "\(hostId)/win=\(inWindow)/area=\(area)" }() let windowMetaLabel: String = { let title = debugString(item["window_title"]) ?? "nil" let windowClass = debugString(item["window_class"]) ?? "nil" let controllerClass = debugString(item["window_controller_class"]) ?? "nil" let delegateClass = debugString(item["window_delegate_class"]) ?? "nil" return "title=\(title) class=\(windowClass) controller=\(controllerClass) delegate=\(delegateClass)" }() let line1 = "[\(index)] \(surface)\(titleSuffix) " + "mapped=\(debugFlag(item["mapped"])) tree=\(debugFlag(item["tree_visible"])) " + "window=\(window) workspace=\(workspace) pane=\(pane) bonsplitTab=\(bonsplitTab) " + "ctx=\(debugString(item["surface_context"]) ?? "nil")" let line2 = " runtime=\(debugFlag(item["runtime_surface_ready"])) " + "focused=\(debugFlag(item["surface_focused"])) " + "selected=\(debugFlag(item["surface_selected_in_pane"])) " + "pinned=\(debugFlag(item["surface_pinned"])) " + "terminal=\(debugString(item["terminal_object_ptr"]) ?? "nil") " + "hosted=\(debugString(item["hosted_view_ptr"]) ?? "nil") " + "ghostty=\(debugString(item["ghostty_surface_ptr"]) ?? "nil") " + "portal=\(debugString(item["portal_binding_state"]) ?? "nil")#\(debugString(item["portal_binding_generation"]) ?? "nil") " + "teardown=\(teardownLabel)" let line3 = " tty=\(debugString(item["tty"]) ?? "nil") " + "cwd=\(debugString(item["current_directory"]) ?? debugString(item["requested_working_directory"]) ?? "nil") " + "branch=\(branchLabel) " + "ports=\(formatDebugPorts(item["listening_ports"])) " + "visible=\(debugFlag(item["hosted_view_visible_in_ui"])) " + "inWindow=\(debugFlag(item["hosted_view_in_window"])) " + "superview=\(debugFlag(item["hosted_view_has_superview"])) " + "hidden=\(debugFlag(item["hosted_view_hidden"])) " + "ancestorHidden=\(debugFlag(item["hosted_view_hidden_or_ancestor_hidden"])) " + "firstResponder=\(debugFlag(item["surface_view_first_responder"])) " + "windowNum=\(debugString(item["window_number"]) ?? "nil") " + "windowKey=\(debugFlag(item["window_key"])) " + "frame=\(formatDebugRect(item["hosted_view_frame_in_window"]) ?? "nil")" let line4 = " created=\(formatDebugAge(item["surface_age_seconds"]) ?? "nil") " + "runtimeCreated=\(formatDebugAge(item["runtime_surface_age_seconds"]) ?? "nil") " + "lastWorkspace=\(lastKnownWorkspace) " + "initialCommand=\(debugString(item["initial_command"]) ?? "nil") " + "portalHost=\(portalHostLabel)" let line5 = " window=\(windowMetaLabel) " + "chain=\(formatDebugList(item["hosted_view_superview_chain"]) ?? "nil")" return [line1, line2, line3, line4, line5].joined(separator: "\n") } .joined(separator: "\n") } private func runMoveSurface( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? commandArgs.first guard let surfaceRaw else { throw CLIError(message: "move-surface requires --surface <id|ref|index>") } let workspaceRaw = optionValue(commandArgs, name: "--workspace") let windowRaw = optionValue(commandArgs, name: "--window") let paneRaw = optionValue(commandArgs, name: "--pane") let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-surface") let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-surface") let windowHandle = try normalizeWindowHandle(windowRaw, client: client) let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle) let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle, allowFocused: false) let paneHandle = try normalizePaneHandle(paneRaw, client: client, workspaceHandle: workspaceHandle) let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle) let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle) var params: [String: Any] = [:] if let surfaceHandle { params["surface_id"] = surfaceHandle } if let paneHandle { params["pane_id"] = paneHandle } if let workspaceHandle { params["workspace_id"] = workspaceHandle } if let windowHandle { params["window_id"] = windowHandle } if let beforeHandle { params["before_surface_id"] = beforeHandle } if let afterHandle { params["after_surface_id"] = afterHandle } if let indexRaw = optionValue(commandArgs, name: "--index") { guard let index = Int(indexRaw) else { throw CLIError(message: "--index must be an integer") } params["index"] = index } if let focusRaw = optionValue(commandArgs, name: "--focus") { guard let focus = parseBoolString(focusRaw) else { throw CLIError(message: "--focus must be true|false") } params["focus"] = focus } let payload = try client.sendV2(method: "surface.move", params: params) let summary = "OK surface=\(formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown") pane=\(formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") window=\(formatHandle(payload, kind: "window", idFormat: idFormat) ?? "unknown")" printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } private func runReorderSurface( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? commandArgs.first guard let surfaceRaw else { throw CLIError(message: "reorder-surface requires --surface <id|ref|index>") } let workspaceRaw = optionValue(commandArgs, name: "--workspace") let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) let surfaceHandle = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceHandle) let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-surface") let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-surface") let beforeHandle = try normalizeSurfaceHandle(beforeRaw, client: client, workspaceHandle: workspaceHandle) let afterHandle = try normalizeSurfaceHandle(afterRaw, client: client, workspaceHandle: workspaceHandle) var params: [String: Any] = [:] if let surfaceHandle { params["surface_id"] = surfaceHandle } if let beforeHandle { params["before_surface_id"] = beforeHandle } if let afterHandle { params["after_surface_id"] = afterHandle } if let indexRaw = optionValue(commandArgs, name: "--index") { guard let index = Int(indexRaw) else { throw CLIError(message: "--index must be an integer") } params["index"] = index } let payload = try client.sendV2(method: "surface.reorder", params: params) let summary = "OK surface=\(formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown") pane=\(formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown") workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown")" printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } private func runReorderWorkspace( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? commandArgs.first guard let workspaceRaw else { throw CLIError(message: "reorder-workspace requires --workspace <id|ref|index>") } let windowRaw = optionValue(commandArgs, name: "--window") let windowHandle = try normalizeWindowHandle(windowRaw, client: client) let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client, windowHandle: windowHandle) let beforeRaw = optionValue(commandArgs, name: "--before") ?? optionValue(commandArgs, name: "--before-workspace") let afterRaw = optionValue(commandArgs, name: "--after") ?? optionValue(commandArgs, name: "--after-workspace") let beforeHandle = try normalizeWorkspaceHandle(beforeRaw, client: client, windowHandle: windowHandle) let afterHandle = try normalizeWorkspaceHandle(afterRaw, client: client, windowHandle: windowHandle) var params: [String: Any] = [:] if let workspaceHandle { params["workspace_id"] = workspaceHandle } if let beforeHandle { params["before_workspace_id"] = beforeHandle } if let afterHandle { params["after_workspace_id"] = afterHandle } if let indexRaw = optionValue(commandArgs, name: "--index") { guard let index = Int(indexRaw) else { throw CLIError(message: "--index must be an integer") } params["index"] = index } if let windowHandle { params["window_id"] = windowHandle } let payload = try client.sendV2(method: "workspace.reorder", params: params) let summary = "OK workspace=\(formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? "unknown") window=\(formatHandle(payload, kind: "window", idFormat: idFormat) ?? "unknown") index=\(payload["index"] ?? "?")" printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } private func runWorkspaceAction( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat, windowOverride: String? ) throws { let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") let (actionOpt, rem1) = parseOption(rem0, name: "--action") let (titleOpt, rem2) = parseOption(rem1, name: "--title") let (colorOpt, rem3) = parseOption(rem2, name: "--color") var positional = rem3 let actionRaw: String if let actionOpt { actionRaw = actionOpt } else if let first = positional.first { actionRaw = first positional.removeFirst() } else { throw CLIError(message: "workspace-action requires --action <name>") } if let unknown = positional.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "workspace-action: unknown flag '\(unknown)'") } let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_") let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) let inferredPositional = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) let title = (titleOpt ?? (action == "rename" && !inferredPositional.isEmpty ? inferredPositional : nil))?.trimmingCharacters(in: .whitespacesAndNewlines) if action == "rename", (title?.isEmpty ?? true) { throw CLIError(message: "workspace-action rename requires --title <text> (or a trailing title)") } let color = ( colorOpt ?? (action == "set_color" ? (inferredPositional.isEmpty ? nil : inferredPositional) : nil) )?.trimmingCharacters(in: .whitespacesAndNewlines) if action == "set_color", (color?.isEmpty ?? true) { throw CLIError(message: "workspace-action set-color requires --color <name|#hex> (or a trailing color)") } var params: [String: Any] = ["action": action] if let workspaceId { params["workspace_id"] = workspaceId } if let title, !title.isEmpty { params["title"] = title } if let color, !color.isEmpty { params["color"] = color } let payload = try client.sendV2(method: "workspace.action", params: params) var summaryParts = ["OK", "action=\(action)"] if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { summaryParts.append("workspace=\(workspaceHandle)") } if let windowHandle = formatHandle(payload, kind: "window", idFormat: idFormat) { summaryParts.append("window=\(windowHandle)") } if let closed = payload["closed"] { summaryParts.append("closed=\(closed)") } if let index = payload["index"] { summaryParts.append("index=\(index)") } if let color = payload["color"] as? String { summaryParts.append("color=\(color)") } printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } private func runTabAction( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat, windowOverride: String? ) throws { let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") let (tabOpt, rem1) = parseOption(rem0, name: "--tab") let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") let (actionOpt, rem3) = parseOption(rem2, name: "--action") let (titleOpt, rem4) = parseOption(rem3, name: "--title") let (urlOpt, rem5) = parseOption(rem4, name: "--url") var positional = rem5 let actionRaw: String if let actionOpt { actionRaw = actionOpt } else if let first = positional.first { actionRaw = first positional.removeFirst() } else { throw CLIError(message: "tab-action requires --action <name>") } if let unknown = positional.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "tab-action: unknown flag '\(unknown)'") } let action = actionRaw.lowercased().replacingOccurrences(of: "-", with: "_") let workspaceArg = workspaceOpt ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let tabArg = tabOpt ?? surfaceOpt ?? (workspaceOpt == nil && windowOverride == nil ? (ProcessInfo.processInfo.environment["CMUX_TAB_ID"] ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]) : nil) let workspaceId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) // If a workspace is explicitly targeted and no tab/surface is provided, let server-side // tab.action resolve that workspace's focused tab instead of using global focus. let allowFocusedFallback = (workspaceId == nil) let surfaceId = try normalizeTabHandle( tabArg, client: client, workspaceHandle: workspaceId, allowFocused: allowFocusedFallback ) let inferredTitle = positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))?.trimmingCharacters(in: .whitespacesAndNewlines) if action == "rename", (title?.isEmpty ?? true) { throw CLIError(message: "tab-action rename requires --title <text> (or a trailing title)") } var params: [String: Any] = ["action": action] if let workspaceId { params["workspace_id"] = workspaceId } if let surfaceId { params["surface_id"] = surfaceId } if let title, !title.isEmpty { params["title"] = title } if let urlOpt, !urlOpt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { params["url"] = urlOpt.trimmingCharacters(in: .whitespacesAndNewlines) } let payload = try client.sendV2(method: "tab.action", params: params) var summaryParts = ["OK", "action=\(action)"] if let tabHandle = formatTabHandle(payload, idFormat: idFormat) { summaryParts.append("tab=\(tabHandle)") } if let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) { summaryParts.append("workspace=\(workspaceHandle)") } if let closed = payload["closed"] { summaryParts.append("closed=\(closed)") } if let created = formatCreatedTabHandle(payload, idFormat: idFormat) { summaryParts.append("created=\(created)") } printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } private func runRenameTab( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat, windowOverride: String? ) throws { let (workspaceOpt, rem0) = parseOption(commandArgs, name: "--workspace") let (tabOpt, rem1) = parseOption(rem0, name: "--tab") let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") let (titleOpt, rem3) = parseOption(rem2, name: "--title") if rem3.contains("--action") { throw CLIError(message: "rename-tab does not accept --action (it always performs rename)") } if let unknown = rem3.first(where: { $0.hasPrefix("--") && $0 != "--" }) { throw CLIError(message: "rename-tab: unknown flag '\(unknown)'") } let inferredTitle = rem3 .dropFirst(rem3.first == "--" ? 1 : 0) .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) let title = (titleOpt ?? (inferredTitle.isEmpty ? nil : inferredTitle))? .trimmingCharacters(in: .whitespacesAndNewlines) guard let title, !title.isEmpty else { throw CLIError(message: "rename-tab requires a title") } var forwarded: [String] = ["--action", "rename", "--title", title] if let workspaceOpt { forwarded += ["--workspace", workspaceOpt] } if let tabOpt { forwarded += ["--tab", tabOpt] } else if let surfaceOpt { forwarded += ["--surface", surfaceOpt] } try runTabAction( commandArgs: forwarded, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowOverride ) } struct SSHCommandOptions { let destination: String let port: Int? let identityFile: String? let workspaceName: String? let noFocus: Bool let sshOptions: [String] let extraArguments: [String] let localSocketPath: String let remoteRelayPort: Int } private struct RemoteDaemonManifest: Decodable { struct Entry: Decodable { let goOS: String let goArch: String let assetName: String let downloadURL: String let sha256: String } let schemaVersion: Int let appVersion: String let releaseTag: String let releaseURL: String let checksumsAssetName: String let checksumsURL: String let entries: [Entry] func entry(goOS: String, goArch: String) -> Entry? { entries.first { $0.goOS == goOS && $0.goArch == goArch } } } private func generateRemoteRelayPort() -> Int { // Random port in the ephemeral range (49152-65535) Int.random(in: 49152...65535) } private func randomHex(byteCount: Int) throws -> String { var bytes = [UInt8](repeating: 0, count: byteCount) let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) guard status == errSecSuccess else { throw CLIError(message: "failed to generate SSH relay credential") } return bytes.map { String(format: "%02x", $0) }.joined() } private func runSSH( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { let sshStartedAt = Date() // Use the socket path from this invocation (supports --socket overrides). let localSocketPath = client.socketPath let remoteRelayPort = generateRemoteRelayPort() let relayID = UUID().uuidString.lowercased() let relayToken = try randomHex(byteCount: 32) let sshOptions = try parseSSHCommandOptions(commandArgs, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort) func logSSHTiming(_ stage: String, extra: String = "") { let elapsedMs = Int(Date().timeIntervalSince(sshStartedAt) * 1000) let suffix = extra.isEmpty ? "" : " \(extra)" cliDebugLog( "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "stage=\(stage) elapsedMs=\(elapsedMs)\(suffix)" ) } logSSHTiming("parsed") let terminfoSource = localXtermGhosttyTerminfoSource() cliDebugLog( "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "stage=terminfo elapsedMs=0 mode=deferred term=xterm-256color " + "source=\(terminfoSource == nil ? 0 : 1)" ) let shellFeaturesValue = scopedGhosttyShellFeaturesValue() let initialSSHCommand = buildSSHCommandText(sshOptions) let remoteTerminalBootstrapScript = sshOptions.extraArguments.isEmpty ? buildInteractiveRemoteShellScript( remoteRelayPort: sshOptions.remoteRelayPort, shellFeatures: shellFeaturesValue, terminfoSource: terminfoSource ) : nil let remoteTerminalSSHCommand = buildSSHCommandText( sshOptions, remoteBootstrapScript: remoteTerminalBootstrapScript ) let initialSSHStartupCommand: String let remoteTerminalSSHStartupCommand: String if let remoteTerminalBootstrapScript, !remoteTerminalBootstrapScript.isEmpty { let bootstrapSSHStartupCommand = try buildBootstrapSSHStartupCommand( options: sshOptions, remoteBootstrapScript: remoteTerminalBootstrapScript, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort ) initialSSHStartupCommand = bootstrapSSHStartupCommand remoteTerminalSSHStartupCommand = bootstrapSSHStartupCommand } else { initialSSHStartupCommand = try buildSSHStartupCommand( sshCommand: initialSSHCommand, shellFeatures: "", remoteRelayPort: sshOptions.remoteRelayPort ) remoteTerminalSSHStartupCommand = try buildSSHStartupCommand( sshCommand: remoteTerminalSSHCommand, shellFeatures: shellFeaturesValue, remoteRelayPort: sshOptions.remoteRelayPort ) } let remoteSSHOptions = effectiveSSHOptions( sshOptions.sshOptions, remoteRelayPort: sshOptions.remoteRelayPort ) cliDebugLog( "cli.ssh.start target=\(sshOptions.destination) port=\(sshOptions.port.map(String.init) ?? "nil") " + "relayPort=\(sshOptions.remoteRelayPort) localSocket=\(sshOptions.localSocketPath) " + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + "workspaceName=\(sshOptions.workspaceName?.replacingOccurrences(of: " ", with: "_") ?? "nil") " + "extraArgs=\(sshOptions.extraArguments.count)" ) let workspaceCreateParams: [String: Any] = [ "initial_command": initialSSHStartupCommand, ] let workspaceCreateStartedAt = Date() let workspaceCreate = try client.sendV2(method: "workspace.create", params: workspaceCreateParams) guard let workspaceId = workspaceCreate["workspace_id"] as? String, !workspaceId.isEmpty else { throw CLIError(message: "workspace.create did not return workspace_id") } let workspaceWindowId = (workspaceCreate["window_id"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) cliDebugLog( "cli.ssh.workspace.created workspace=\(String(workspaceId.prefix(8))) " + "window=\(workspaceWindowId.map { String($0.prefix(8)) } ?? "nil")" ) cliDebugLog( "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.create elapsedMs=\(Int(Date().timeIntervalSince(workspaceCreateStartedAt) * 1000))" ) let configuredPayload: [String: Any] do { if let workspaceName = sshOptions.workspaceName?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceName.isEmpty { _ = try client.sendV2(method: "workspace.rename", params: [ "workspace_id": workspaceId, "title": workspaceName, ]) } var configureParams: [String: Any] = [ "workspace_id": workspaceId, "destination": sshOptions.destination, "auto_connect": true, ] if let port = sshOptions.port { configureParams["port"] = port } if let identityFile = normalizedSSHIdentityPath(sshOptions.identityFile) { configureParams["identity_file"] = identityFile } if !remoteSSHOptions.isEmpty { configureParams["ssh_options"] = remoteSSHOptions } if sshOptions.remoteRelayPort > 0 { configureParams["relay_port"] = sshOptions.remoteRelayPort configureParams["relay_id"] = relayID configureParams["relay_token"] = relayToken configureParams["local_socket_path"] = sshOptions.localSocketPath } configureParams["terminal_startup_command"] = remoteTerminalSSHStartupCommand cliDebugLog( "cli.ssh.remote.configure workspace=\(String(workspaceId.prefix(8))) " + "target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "controlPath=\(sshOptionValue(named: "ControlPath", in: remoteSSHOptions) ?? "nil") " + "sshOptions=\(remoteSSHOptions.joined(separator: "|"))" ) let configureStartedAt = Date() configuredPayload = try client.sendV2(method: "workspace.remote.configure", params: configureParams) var selectParams: [String: Any] = ["workspace_id": workspaceId] if let workspaceWindowId, !workspaceWindowId.isEmpty { selectParams["window_id"] = workspaceWindowId } // `cmux ssh` is an explicit "open this remote workspace now" action, // so we intentionally select the newly created workspace after wiring // up the remote connection — unless --no-focus is passed. if !sshOptions.noFocus { _ = try client.sendV2(method: "workspace.select", params: selectParams) } let remoteState = ((configuredPayload["remote"] as? [String: Any])?["state"] as? String) ?? "unknown" cliDebugLog( "cli.ssh.remote.configure.ok workspace=\(String(workspaceId.prefix(8))) state=\(remoteState)" ) cliDebugLog( "cli.ssh.timing target=\(sshOptions.destination) relayPort=\(sshOptions.remoteRelayPort) " + "workspace=\(String(workspaceId.prefix(8))) stage=workspace.remote.configure elapsedMs=\(Int(Date().timeIntervalSince(configureStartedAt) * 1000))" ) } catch { cliDebugLog( "cli.ssh.remote.configure.error workspace=\(String(workspaceId.prefix(8))) error=\(String(describing: error))" ) do { _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) } catch { let warning = "Warning: failed to rollback workspace \(workspaceId): \(error)\n" FileHandle.standardError.write(Data(warning.utf8)) } throw error } var payload = configuredPayload payload["ssh_command"] = initialSSHCommand payload["ssh_startup_command"] = initialSSHStartupCommand payload["ssh_terminal_command"] = remoteTerminalSSHCommand payload["ssh_terminal_startup_command"] = remoteTerminalSSHStartupCommand payload["ssh_env_overrides"] = [ "GHOSTTY_SHELL_FEATURES": shellFeaturesValue, ] payload["remote_relay_port"] = remoteRelayPort logSSHTiming("complete", extra: "workspace=\(String(workspaceId.prefix(8)))") if jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let workspaceHandle = formatHandle(payload, kind: "workspace", idFormat: idFormat) ?? workspaceId let remote = payload["remote"] as? [String: Any] let state = (remote?["state"] as? String) ?? "unknown" print("OK workspace=\(workspaceHandle) target=\(sshOptions.destination) state=\(state)") } } private func parseSSHCommandOptions(_ commandArgs: [String], localSocketPath: String = "", remoteRelayPort: Int = 0) throws -> SSHCommandOptions { var destination: String? var port: Int? var identityFile: String? var workspaceName: String? var noFocus = false var sshOptions: [String] = [] var extraArguments: [String] = [] var passthrough = false var index = 0 while index < commandArgs.count { let arg = commandArgs[index] if passthrough { extraArguments.append(arg) index += 1 continue } switch arg { case "--": passthrough = true index += 1 case "--port": guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh: --port requires a value") } guard let parsed = Int(commandArgs[index + 1]), parsed > 0, parsed <= 65535 else { throw CLIError(message: "ssh: --port must be 1-65535") } port = parsed index += 2 case "--identity": guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh: --identity requires a path") } identityFile = commandArgs[index + 1] index += 2 case "--name": guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh: --name requires a workspace title") } workspaceName = commandArgs[index + 1] index += 2 case "--no-focus": noFocus = true index += 1 case "--ssh-option": guard index + 1 < commandArgs.count else { throw CLIError(message: "ssh: --ssh-option requires a value") } let value = commandArgs[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) if !value.isEmpty { sshOptions.append(value) } index += 2 default: if arg.hasPrefix("--") { throw CLIError(message: "ssh: unknown flag '\(arg)'") } if destination == nil { if arg.hasPrefix("-") { throw CLIError( message: "ssh: destination must be <user@host>. Use --port/--identity/--ssh-option for SSH flags and `--` for remote command args." ) } destination = arg } else { extraArguments.append(arg) } index += 1 } } guard let destination else { throw CLIError(message: "ssh requires a destination (example: cmux ssh user@host)") } return SSHCommandOptions( destination: destination, port: port, identityFile: identityFile, workspaceName: workspaceName, noFocus: noFocus, sshOptions: sshOptions, extraArguments: extraArguments, localSocketPath: localSocketPath, remoteRelayPort: remoteRelayPort ) } func buildSSHCommandText( _ options: SSHCommandOptions, remoteBootstrapScript: String? = nil ) -> String { var parts = baseSSHArguments(options) let trimmedRemoteBootstrap = remoteBootstrapScript? .trimmingCharacters(in: .whitespacesAndNewlines) if options.extraArguments.isEmpty { if let trimmedRemoteBootstrap, !trimmedRemoteBootstrap.isEmpty { let remoteCommand = sshPercentEscapedRemoteCommand( encodedRemoteBootstrapCommand(trimmedRemoteBootstrap) ) parts += ["-o", "RemoteCommand=\(remoteCommand)"] } if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { parts.append("-tt") } parts.append(options.destination) } else { parts.append(options.destination) parts.append(contentsOf: options.extraArguments) } return parts.map(shellQuote).joined(separator: " ") } func buildBootstrapSSHStartupCommand( options: SSHCommandOptions, remoteBootstrapScript: String, shellFeatures: String, remoteRelayPort: Int ) throws -> String { let commandSnippet = buildSSHBootstrapCommandSnippet( options: options, remoteBootstrapScript: remoteBootstrapScript ) return try buildSSHStartupCommand( sshCommand: commandSnippet, shellFeatures: shellFeatures, remoteRelayPort: remoteRelayPort, isShellSnippet: true ) } private func buildSSHBootstrapCommandSnippet( options: SSHCommandOptions, remoteBootstrapScript: String ) -> String { let encodedBootstrapScript = Data(remoteBootstrapScript.utf8).base64EncodedString() let sshPrefix = baseSSHArguments(options).map(shellQuote).joined(separator: " ") let remoteCommandBase64Placeholder = "__CMUX_REMOTE_BOOTSTRAP_B64_RUNTIME__" let remoteCommandTemplate = sshPercentEscapedRemoteCommand( runtimeEncodedRemoteBootstrapCommandShell( base64Placeholder: remoteCommandBase64Placeholder ) ) var lines: [String] = [ "cmux_workspace_id=\"${CMUX_WORKSPACE_ID:-}\"", "cmux_surface_id=\"${CMUX_SURFACE_ID:-}\"", "cmux_remote_bootstrap_b64=\(shellQuote(encodedBootstrapScript))", "cmux_remote_bootstrap=\"$(printf %s \"$cmux_remote_bootstrap_b64\" | base64 -d 2>/dev/null || printf %s \"$cmux_remote_bootstrap_b64\" | base64 -D 2>/dev/null)\"", "cmux_remote_bootstrap=\"$(printf '%s' \"$cmux_remote_bootstrap\" | sed \"s/__CMUX_WORKSPACE_ID__/$cmux_workspace_id/g; s/__CMUX_SURFACE_ID__/$cmux_surface_id/g\")\"", "cmux_remote_bootstrap_b64_runtime=\"$(printf '%s' \"$cmux_remote_bootstrap\" | base64 | tr -d '\\n')\"", "cmux_remote_command_template=\(shellQuote(remoteCommandTemplate))", "cmux_remote_command=\"$(printf '%s' \"$cmux_remote_command_template\" | sed \"s|\(remoteCommandBase64Placeholder)|$cmux_remote_bootstrap_b64_runtime|g\")\"", ] var sshInvocation = "command \(sshPrefix) -o \"RemoteCommand=$cmux_remote_command\"" if !hasSSHOptionKey(options.sshOptions, key: "RequestTTY") { sshInvocation += " -tt" } sshInvocation += " " + shellQuote(options.destination) lines.append(sshInvocation) return lines.joined(separator: "\n") } private func runtimeEncodedRemoteBootstrapCommandShell(base64Placeholder: String) -> String { return [ "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", "(printf %s '\(base64Placeholder)' | base64 -d 2>/dev/null || printf %s '\(base64Placeholder)' | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", "/bin/sh \"$cmux_tmp\"", "cmux_status=$?", "rm -f \"$cmux_tmp\"", "exit $cmux_status", ].joined(separator: "; ") } private func effectiveSSHOptions(_ options: [String], remoteRelayPort: Int? = nil) -> [String] { var merged = sshOptionsWithControlSocketDefaults(options, remoteRelayPort: remoteRelayPort) if !hasSSHOptionKey(merged, key: "StrictHostKeyChecking") { merged.append("StrictHostKeyChecking=accept-new") } return merged } func buildInteractiveRemoteShellScript( remoteRelayPort: Int, shellFeatures: String, terminfoSource: String? = nil ) -> String { let remoteTerminalLines = interactiveRemoteTerminalSetupLines(terminfoSource: terminfoSource) let remoteEnvExportLines = interactiveRemoteShellExportLines(shellFeatures: shellFeatures) let remoteCallerExportLines = [ "if [ -n '__CMUX_WORKSPACE_ID__' ]; then export CMUX_WORKSPACE_ID='__CMUX_WORKSPACE_ID__'; fi", "if [ -n '__CMUX_SURFACE_ID__' ]; then export CMUX_SURFACE_ID='__CMUX_SURFACE_ID__'; fi", ] let relaySocket = remoteRelayPort > 0 ? "127.0.0.1:\(remoteRelayPort)" : nil let shellStateDir = "$HOME/.cmux/relay/\(max(remoteRelayPort, 0)).shell" var commonShellLines = remoteTerminalLines commonShellLines.append(contentsOf: remoteEnvExportLines) commonShellLines.append("export PATH=\"$HOME/.cmux/bin:$PATH\"") if let relaySocket { commonShellLines.append("export CMUX_SOCKET_PATH=\(relaySocket)") } commonShellLines.append(contentsOf: remoteCallerExportLines) commonShellLines.append(contentsOf: [ "hash -r >/dev/null 2>&1 || true", "rehash >/dev/null 2>&1 || true", ]) let zshBootstrap = RemoteRelayZshBootstrap(shellStateDir: shellStateDir) let zshEnvLines = zshBootstrap.zshEnvLines let zshProfileLines = zshBootstrap.zshProfileLines let zshRCLines = zshBootstrap.zshRCLines(commonShellLines: commonShellLines) let zshLoginLines = zshBootstrap.zshLoginLines let bashRCLines = [ "if [ -f \"$HOME/.bash_profile\" ]; then . \"$HOME/.bash_profile\"; elif [ -f \"$HOME/.bash_login\" ]; then . \"$HOME/.bash_login\"; elif [ -f \"$HOME/.profile\" ]; then . \"$HOME/.profile\"; fi", "[ -f \"$HOME/.bashrc\" ] && . \"$HOME/.bashrc\"", ] + commonShellLines let relayWarmupLines = interactiveRemoteRelayWarmupLines(remoteRelayPort: remoteRelayPort) var outerLines: [String] = [ "CMUX_LOGIN_SHELL=\"${SHELL:-/bin/zsh}\"", "case \"${CMUX_LOGIN_SHELL##*/}\" in", " zsh)", " mkdir -p \"$HOME/.cmux/relay\"", " cmux_shell_dir=\"\(shellStateDir)\"", " mkdir -p \"$cmux_shell_dir\"", " cat > \"$cmux_shell_dir/.zshenv\" <<'CMUXZSHENV'", ] outerLines.append(contentsOf: zshEnvLines) outerLines += [ "CMUXZSHENV", " cat > \"$cmux_shell_dir/.zprofile\" <<'CMUXZSHPROFILE'", ] outerLines.append(contentsOf: zshProfileLines) outerLines += [ "CMUXZSHPROFILE", " cat > \"$cmux_shell_dir/.zshrc\" <<'CMUXZSHRC'", ] outerLines.append(contentsOf: zshRCLines) outerLines += [ "CMUXZSHRC", " cat > \"$cmux_shell_dir/.zlogin\" <<'CMUXZSHLOGIN'", ] outerLines.append(contentsOf: zshLoginLines) outerLines += [ "CMUXZSHLOGIN", " chmod 600 \"$cmux_shell_dir/.zshenv\" \"$cmux_shell_dir/.zprofile\" \"$cmux_shell_dir/.zshrc\" \"$cmux_shell_dir/.zlogin\" >/dev/null 2>&1 || true", ] outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines += [ " export CMUX_REAL_ZDOTDIR=\"${ZDOTDIR:-$HOME}\"", " export ZDOTDIR=\"$cmux_shell_dir\"", " exec \"$CMUX_LOGIN_SHELL\" -il", " ;;", " bash)", " mkdir -p \"$HOME/.cmux/relay\"", " cmux_shell_dir=\"\(shellStateDir)\"", " mkdir -p \"$cmux_shell_dir\"", " cat > \"$cmux_shell_dir/.bashrc\" <<'CMUXBASHRC'", ] outerLines.append(contentsOf: bashRCLines) outerLines += [ "CMUXBASHRC", " chmod 600 \"$cmux_shell_dir/.bashrc\" >/dev/null 2>&1 || true", ] outerLines.append(contentsOf: relayWarmupLines.map { " " + $0 }) outerLines += [ " exec \"$CMUX_LOGIN_SHELL\" --rcfile \"$cmux_shell_dir/.bashrc\" -i", " ;;", " *)", ] outerLines.append(contentsOf: commonShellLines) outerLines.append(contentsOf: relayWarmupLines) outerLines += [ "exec \"$CMUX_LOGIN_SHELL\" -i", ";;", "esac", ] return outerLines.joined(separator: "\n") } func buildInteractiveRemoteShellCommand( remoteRelayPort: Int, shellFeatures: String, terminfoSource: String? = nil ) -> String { let script = buildInteractiveRemoteShellScript( remoteRelayPort: remoteRelayPort, shellFeatures: shellFeatures, terminfoSource: terminfoSource ) return "/bin/sh -c \(shellQuote(script))" } private func interactiveRemoteTerminalSetupLines(terminfoSource: String?) -> [String] { var lines: [String] = [ "cmux_term='xterm-256color'", "if command -v infocmp >/dev/null 2>&1 && infocmp xterm-ghostty >/dev/null 2>&1; then", " cmux_term='xterm-ghostty'", "fi", "export TERM=\"$cmux_term\"", ] guard let terminfoSource else { return lines } let trimmedTerminfoSource = terminfoSource.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedTerminfoSource.isEmpty else { return lines } lines += [ "if [ \"$cmux_term\" != 'xterm-ghostty' ]; then", " (", " command -v tic >/dev/null 2>&1 || exit 0", " mkdir -p \"$HOME/.terminfo\" 2>/dev/null || exit 0", " cat <<'CMUXTERMINFO' | tic -x - >/dev/null 2>&1", trimmedTerminfoSource, "CMUXTERMINFO", " ) >/dev/null 2>&1 &", "fi", ] return lines } private func interactiveRemoteShellExportLines(shellFeatures: String) -> [String] { let environment = ProcessInfo.processInfo.environment let colorTerm = Self.normalizedEnvValue(environment["COLORTERM"]) ?? "truecolor" let termProgram = Self.normalizedEnvValue(environment["TERM_PROGRAM"]) ?? "ghostty" let termProgramVersion = Self.normalizedEnvValue(environment["TERM_PROGRAM_VERSION"]) ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) ?? "" let trimmedShellFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) var exports: [String] = [ "export COLORTERM=\(shellQuote(colorTerm))", "export TERM_PROGRAM=\(shellQuote(termProgram))", ] if !termProgramVersion.isEmpty { exports.append("export TERM_PROGRAM_VERSION=\(shellQuote(termProgramVersion))") } if !trimmedShellFeatures.isEmpty { exports.append("export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedShellFeatures))") } return exports } private func interactiveRemoteRelayWarmupLines(remoteRelayPort: Int) -> [String] { guard remoteRelayPort > 0 else { return [] } return [] } private func baseSSHArguments(_ options: SSHCommandOptions) -> [String] { let effectiveSSHOptions = effectiveSSHOptions( options.sshOptions, remoteRelayPort: options.remoteRelayPort ) var parts: [String] = ["ssh"] if !hasSSHOptionKey(effectiveSSHOptions, key: "ConnectTimeout") { parts += ["-o", "ConnectTimeout=6"] } if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveInterval") { parts += ["-o", "ServerAliveInterval=20"] } if !hasSSHOptionKey(effectiveSSHOptions, key: "ServerAliveCountMax") { parts += ["-o", "ServerAliveCountMax=2"] } if !hasSSHOptionKey(effectiveSSHOptions, key: "SetEnv") { parts += ["-o", "SetEnv COLORTERM=truecolor"] } if !hasSSHOptionKey(effectiveSSHOptions, key: "SendEnv") { parts += ["-o", "SendEnv TERM_PROGRAM TERM_PROGRAM_VERSION"] } if let port = options.port { parts += ["-p", String(port)] } if let identityFile = normalizedSSHIdentityPath(options.identityFile) { parts += ["-i", identityFile] } for option in effectiveSSHOptions { parts += ["-o", option] } return parts } private func localXtermGhosttyTerminfoSource() -> String? { let result = runProcess( executablePath: "/usr/bin/infocmp", arguments: ["-0", "-x", "xterm-ghostty"] ) guard result.status == 0 else { return nil } let output = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) return output.isEmpty ? nil : output } private func sshOptionsWithControlSocketDefaults( _ options: [String], remoteRelayPort: Int? = nil ) -> [String] { var merged: [String] = [] for option in options { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } merged.append(trimmed) } if !hasSSHOptionKey(merged, key: "ControlMaster") { merged.append("ControlMaster=auto") } if !hasSSHOptionKey(merged, key: "ControlPersist") { merged.append("ControlPersist=600") } if !hasSSHOptionKey(merged, key: "ControlPath") { merged.append("ControlPath=\(defaultSSHControlPathTemplate(remoteRelayPort: remoteRelayPort))") } return merged } private func scopedGhosttyShellFeaturesValue() -> String { let rawExisting = ProcessInfo.processInfo.environment["GHOSTTY_SHELL_FEATURES"] ?? "" var seen: Set<String> = [] var merged: [String] = [] for token in rawExisting.split(separator: ",") { let feature = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !feature.isEmpty else { continue } if seen.insert(feature).inserted { merged.append(feature) } } for required in ["ssh-env", "ssh-terminfo"] { if seen.insert(required).inserted { merged.append(required) } } return merged.joined(separator: ",") } func encodedRemoteBootstrapCommand(_ remoteBootstrapScript: String) -> String { let encodedScript = Data(remoteBootstrapScript.utf8).base64EncodedString() let encodedLiteral = shellQuote(encodedScript) return [ "cmux_tmp=$(mktemp \"${TMPDIR:-/tmp}/cmux-ssh-bootstrap.XXXXXX\") || exit 1", "(printf %s \(encodedLiteral) | base64 -d 2>/dev/null || printf %s \(encodedLiteral) | base64 -D 2>/dev/null) > \"$cmux_tmp\" || { rm -f \"$cmux_tmp\"; exit 1; }", "chmod 700 \"$cmux_tmp\" >/dev/null 2>&1 || true", "/bin/sh \"$cmux_tmp\"", "cmux_status=$?", "rm -f \"$cmux_tmp\"", "exit $cmux_status", ].joined(separator: "; ") } func sshPercentEscapedRemoteCommand(_ remoteCommand: String) -> String { remoteCommand.replacingOccurrences(of: "%", with: "%%") } func buildSSHStartupCommand( sshCommand: String, shellFeatures: String, remoteRelayPort: Int, isShellSnippet: Bool = false ) throws -> String { let trimmedFeatures = shellFeatures.trimmingCharacters(in: .whitespacesAndNewlines) let shellFeaturesBootstrap: String = trimmedFeatures.isEmpty ? "" : "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))" let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort) var scriptLines: [String] = [] if !shellFeaturesBootstrap.isEmpty { scriptLines.append(shellFeaturesBootstrap) } scriptLines += [ "CMUX_SSH_SESSION_ENDED=0", "cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; \(lifecycleCleanup); }", "trap 'cmux_ssh_session_end' EXIT HUP INT TERM", ] if isShellSnippet { scriptLines.append(sshCommand) } else { scriptLines.append("command \(sshCommand)") } scriptLines += [ "cmux_ssh_status=$?", "trap - EXIT HUP INT TERM", "cmux_ssh_session_end", "exit $cmux_ssh_status", ] let script = scriptLines.joined(separator: "\n") return try writeSSHStartupScript(script, remoteRelayPort: remoteRelayPort) } private func writeSSHStartupScript(_ scriptBody: String, remoteRelayPort: Int) throws -> String { let tempDir = FileManager.default.temporaryDirectory let scriptURL = tempDir.appendingPathComponent( "cmux-ssh-startup-\(remoteRelayPort)-\(UUID().uuidString.lowercased()).sh" ) let script = "#!/bin/sh\n\(scriptBody)\n" try script.write(to: scriptURL, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: scriptURL.path) return shellQuote(scriptURL.path) } private func buildSSHSessionEndShellCommand(remoteRelayPort: Int) -> String { [ "if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]", "&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]", "&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", "\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", "elif command -v cmux >/dev/null 2>&1", "&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]", "&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then", "cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" >/dev/null 2>&1 || true;", "fi", ].joined(separator: " ") } private func runSSHSessionEnd(commandArgs: [String], client: SocketClient) throws { guard let relayPortRaw = optionValue(commandArgs, name: "--relay-port"), let relayPort = Int(relayPortRaw), relayPort > 0 else { throw CLIError(message: "ssh-session-end requires --relay-port <port>") } let workspaceRaw = optionValue(commandArgs, name: "--workspace") ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] let surfaceRaw = optionValue(commandArgs, name: "--surface") ?? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] guard let workspaceRaw, let workspaceId = try normalizeWorkspaceHandle(workspaceRaw, client: client), !workspaceId.isEmpty else { throw CLIError(message: "ssh-session-end requires --workspace or CMUX_WORKSPACE_ID") } guard let surfaceRaw, let surfaceId = try normalizeSurfaceHandle(surfaceRaw, client: client, workspaceHandle: workspaceId), !surfaceId.isEmpty else { throw CLIError(message: "ssh-session-end requires --surface or CMUX_SURFACE_ID") } _ = try client.sendV2(method: "workspace.remote.terminal_session_end", params: [ "workspace_id": workspaceId, "surface_id": surfaceId, "relay_port": relayPort, ]) } private func runRemoteDaemonStatus(commandArgs: [String], jsonOutput: Bool) throws { let requestedOS = optionValue(commandArgs, name: "--os")?.trimmingCharacters(in: .whitespacesAndNewlines) let requestedArch = optionValue(commandArgs, name: "--arch")?.trimmingCharacters(in: .whitespacesAndNewlines) let info = resolvedVersionInfo() let manifest = remoteDaemonManifest() let platform = defaultRemoteDaemonPlatform(requestedOS: requestedOS, requestedArch: requestedArch) let cacheURL = remoteDaemonCacheURL(version: manifest?.appVersion ?? remoteDaemonVersionString(from: info), goOS: platform.goOS, goArch: platform.goArch) let cacheExists = FileManager.default.fileExists(atPath: cacheURL.path) let cacheSHA = cacheExists ? try? sha256Hex(forFile: cacheURL) : nil let entry = manifest?.entry(goOS: platform.goOS, goArch: platform.goArch) let cacheVerified = (entry != nil && cacheSHA?.lowercased() == entry?.sha256.lowercased()) let releaseTag = manifest?.releaseTag ?? "unknown" let assetName = entry?.assetName ?? "unknown" let downloadURL = entry?.downloadURL ?? "unknown" let checksumsAssetName = manifest?.checksumsAssetName ?? "unknown" let checksumsURL = manifest?.checksumsURL ?? "unknown" let downloadCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(assetName)" let downloadChecksumsCommand = "gh release download \(releaseTag) --repo manaflow-ai/cmux --pattern \(checksumsAssetName)" let checksumVerifyCommand = "shasum -a 256 -c \(checksumsAssetName) --ignore-missing" let signerWorkflow = releaseTag == "nightly" ? "manaflow-ai/cmux/.github/workflows/nightly.yml" : "manaflow-ai/cmux/.github/workflows/release.yml" let verifyCommand = "gh attestation verify ./\(assetName) --repo manaflow-ai/cmux --signer-workflow \(signerWorkflow)" let payload: [String: Any] = [ "app_version": remoteDaemonVersionString(from: info), "build": info["CFBundleVersion"] ?? NSNull(), "commit": info["CMUXCommit"] ?? NSNull(), "manifest_present": manifest != nil, "release_tag": releaseTag, "release_url": manifest?.releaseURL ?? NSNull(), "target_goos": platform.goOS, "target_goarch": platform.goArch, "asset_name": assetName, "download_url": downloadURL, "checksums_asset_name": checksumsAssetName, "checksums_url": checksumsURL, "expected_sha256": entry?.sha256 ?? NSNull(), "cache_path": cacheURL.path, "cache_exists": cacheExists, "cache_sha256": cacheSHA ?? NSNull(), "cache_verified": cacheVerified, "dev_local_build_fallback": ProcessInfo.processInfo.environment["CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD"] == "1", "download_command": downloadCommand, "download_checksums_command": downloadChecksumsCommand, "checksum_verify_command": checksumVerifyCommand, "attestation_verify_command": verifyCommand, ] if jsonOutput { print(jsonString(payload)) return } print("app version: \(payload["app_version"] as? String ?? "unknown")") if let build = payload["build"] as? String { print("build: \(build)") } if let commit = payload["commit"] as? String { print("commit: \(commit)") } print("manifest: \(manifest != nil ? "present" : "missing")") print("platform: \(platform.goOS)/\(platform.goArch)") print("release: \(releaseTag)") print("asset: \(assetName)") print("download url: \(downloadURL)") print("checksums asset: \(checksumsAssetName)") print("checksums: \(checksumsURL)") if let expectedSHA = entry?.sha256 { print("expected sha256: \(expectedSHA)") } print("cache: \(cacheURL.path)") print("cache exists: \(cacheExists ? "yes" : "no")") if let cacheSHA { print("cache sha256: \(cacheSHA)") } print("cache verified: \(cacheVerified ? "yes" : "no")") print("download command: \(downloadCommand)") print("download checksums: \(downloadChecksumsCommand)") print("verify checksum: \(checksumVerifyCommand)") print("attestation verify: \(verifyCommand)") if manifest == nil { print("note: this build has no embedded remote daemon manifest. Set CMUX_REMOTE_DAEMON_ALLOW_LOCAL_BUILD=1 only for dev builds.") } } private func defaultRemoteDaemonPlatform(requestedOS: String?, requestedArch: String?) -> (goOS: String, goArch: String) { let normalizedOS = requestedOS? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() let normalizedArch = requestedArch? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() let goOS = (normalizedOS?.isEmpty == false ? normalizedOS! : hostGoOS()) let goArch = (normalizedArch?.isEmpty == false ? normalizedArch! : hostGoArch()) return (goOS, goArch) } private func hostGoOS() -> String { #if os(macOS) return "darwin" #elseif os(Linux) return "linux" #else return "unknown" #endif } private func hostGoArch() -> String { #if arch(arm64) return "arm64" #elseif arch(x86_64) return "amd64" #else return "unknown" #endif } private func remoteDaemonManifest() -> RemoteDaemonManifest? { for plistURL in candidateInfoPlistURLs() { guard let raw = NSDictionary(contentsOf: plistURL) as? [String: Any], let rawManifest = raw["CMUXRemoteDaemonManifestJSON"] as? String, let data = rawManifest.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), let manifest = try? JSONDecoder().decode(RemoteDaemonManifest.self, from: data) else { continue } return manifest } return nil } private func remoteDaemonVersionString(from info: [String: String]) -> String { info["CFBundleShortVersionString"] ?? "dev" } private func remoteDaemonCacheURL(version: String, goOS: String, goArch: String) -> URL { let root: URL do { root = try FileManager.default.url( for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true ) } catch { return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("cmux-remote-daemons", isDirectory: true) .appendingPathComponent(version, isDirectory: true) .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) .appendingPathComponent("cmuxd-remote", isDirectory: false) } return root .appendingPathComponent("cmux", isDirectory: true) .appendingPathComponent("remote-daemons", isDirectory: true) .appendingPathComponent(version, isDirectory: true) .appendingPathComponent("\(goOS)-\(goArch)", isDirectory: true) .appendingPathComponent("cmuxd-remote", isDirectory: false) } private func sha256Hex(forFile url: URL) throws -> String { let data = try Data(contentsOf: url) let digest = SHA256.hash(data: data) return digest.map { String(format: "%02x", $0) }.joined() } private func hasSSHOptionKey(_ options: [String], key: String) -> Bool { let loweredKey = key.lowercased() for option in options { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let token = trimmed.split(whereSeparator: { $0 == "=" || $0.isWhitespace }).first.map(String.init)?.lowercased() if token == loweredKey { return true } } return false } private func defaultSSHControlPathTemplate(remoteRelayPort: Int? = nil) -> String { if let remoteRelayPort, remoteRelayPort > 0 { return "/tmp/cmux-ssh-\(getuid())-\(remoteRelayPort)-%C" } return "/tmp/cmux-ssh-\(getuid())-%C" } private func normalizedSSHIdentityPath(_ rawPath: String?) -> String? { guard let rawPath else { return nil } let trimmed = rawPath.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } if trimmed.hasPrefix("~") { let expanded = (trimmed as NSString).expandingTildeInPath if !expanded.isEmpty { return expanded } } return trimmed } private func shellQuote(_ value: String) -> String { let safePattern = "^[A-Za-z0-9_@%+=:,./-]+$" if value.range(of: safePattern, options: .regularExpression) != nil { return value } return "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } private func sshOptionValue(named key: String, in options: [String]) -> String? { let loweredKey = key.lowercased() for option in options { let trimmed = option.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { continue } let parts = trimmed.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) if parts.count == 2, parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == loweredKey { return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) } } return nil } private func cliDebugLog(_ message: @autoclosure () -> String) { #if DEBUG let trimmedExplicit = ProcessInfo.processInfo.environment["CMUX_DEBUG_LOG"]? .trimmingCharacters(in: .whitespacesAndNewlines) let path: String? = { if let trimmedExplicit, !trimmedExplicit.isEmpty { return trimmedExplicit } guard let marker = try? String(contentsOfFile: "/tmp/cmux-last-debug-log-path", encoding: .utf8) else { return nil } let trimmedMarker = marker.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedMarker.isEmpty ? nil : trimmedMarker }() guard let path else { return } let timestamp = ISO8601DateFormatter().string(from: Date()) let line = "\(timestamp) [cmux-cli] \(message())\n" guard let data = line.data(using: .utf8) else { return } if !FileManager.default.fileExists(atPath: path) { FileManager.default.createFile(atPath: path, contents: nil) } guard let handle = FileHandle(forWritingAtPath: path) else { return } defer { try? handle.close() } do { try handle.seekToEnd() try handle.write(contentsOf: data) } catch { return } #endif } private func runProcess( executablePath: String, arguments: [String], stdinText: String? = nil, timeout: TimeInterval? = nil ) -> (status: Int32, stdout: String, stderr: String) { let result = CLIProcessRunner.runProcess( executablePath: executablePath, arguments: arguments, stdinText: stdinText, timeout: timeout ) return (result.status, result.stdout, result.stderr) } private func runBrowserCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { guard !commandArgs.isEmpty else { throw CLIError(message: "browser requires a subcommand") } var effectiveJSONOutput = jsonOutput var effectiveIDFormat = idFormat var browserArgs = commandArgs // Browser-skill examples often place output flags at the end of the command. // Strip trailing display flags so they don't become part of a URL or selector. while !browserArgs.isEmpty { if browserArgs.last == "--json" { effectiveJSONOutput = true browserArgs.removeLast() continue } if browserArgs.count >= 2, browserArgs[browserArgs.count - 2] == "--id-format" { let raw = browserArgs.last! guard let parsed = try CLIIDFormat.parse(raw) else { throw CLIError(message: "--id-format must be one of: refs, uuids, both") } effectiveIDFormat = parsed browserArgs.removeLast(2) continue } break } let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface") var surfaceRaw = surfaceOpt var args = argsWithoutSurfaceFlag let verbsWithoutSurface: Set<String> = ["open", "open-split", "new", "identify"] if surfaceRaw == nil, let first = args.first { if !first.hasPrefix("-") && !verbsWithoutSurface.contains(first.lowercased()) { surfaceRaw = first args = Array(args.dropFirst()) } } guard let subcommandRaw = args.first else { throw CLIError(message: "browser requires a subcommand") } let subcommand = subcommandRaw.lowercased() let subArgs = Array(args.dropFirst()) func requireSurface() throws -> String { guard let raw = surfaceRaw else { throw CLIError(message: "browser \(subcommand) requires a surface handle (use: browser <surface> \(subcommand) ... or --surface)") } guard let resolved = try normalizeSurfaceHandle(raw, client: client) else { throw CLIError(message: "Invalid surface handle") } return resolved } func output(_ payload: [String: Any], fallback: String) { if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) return } print(fallback) if let snapshot = payload["post_action_snapshot"] as? String, !snapshot.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { print(snapshot) } } func displaySnapshotText(_ payload: [String: Any]) -> String { let snapshotText = (payload["snapshot"] as? String) ?? "Empty page" guard snapshotText.contains("\n- (empty)") else { return snapshotText } let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) var lines = [snapshotText] if !url.isEmpty { lines.append("url: \(url)") } if !readyState.isEmpty { lines.append("ready_state: \(readyState)") } if url.isEmpty || url == "about:blank" { lines.append("hint: run 'cmux browser <surface> get url' to verify navigation") } return lines.joined(separator: "\n") } func displayBrowserValue(_ value: Any) -> String { if let dict = value as? [String: Any], let type = dict["__cmux_t"] as? String, type == "undefined" { return "undefined" } if value is NSNull { return "null" } if let string = value as? String { return string } if let bool = value as? Bool { return bool ? "true" : "false" } if let number = value as? NSNumber { return number.stringValue } if JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), let text = String(data: data, encoding: .utf8) { return text } return String(describing: value) } func displayBrowserLogItems(_ value: Any?) -> String? { guard let items = value as? [Any], !items.isEmpty else { return nil } let lines = items.map { item -> String in guard let dict = item as? [String: Any] else { return displayBrowserValue(item) } let text = (dict["text"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let levelRaw = (dict["level"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let level = levelRaw.isEmpty ? "log" : levelRaw if text.isEmpty { if let message = (dict["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !message.isEmpty { return "[error] \(message)" } return displayBrowserValue(dict) } return "[\(level)] \(text)" } return lines.joined(separator: "\n") } func nonFlagArgs(_ values: [String]) -> [String] { values.filter { !$0.hasPrefix("-") } } if subcommand == "identify" { let surface = try normalizeSurfaceHandle(surfaceRaw, client: client, allowFocused: true) var payload = try client.sendV2(method: "system.identify") if let surface { let urlPayload = try client.sendV2(method: "browser.url.get", params: ["surface_id": surface]) let titlePayload = try client.sendV2(method: "browser.get.title", params: ["surface_id": surface]) var browser: [String: Any] = [:] browser["surface"] = surface browser["url"] = urlPayload["url"] ?? "" browser["title"] = titlePayload["title"] ?? "" payload["browser"] = browser } output(payload, fallback: "OK") return } if subcommand == "open" || subcommand == "open-split" || subcommand == "new" { // Parse routing flags before URL assembly so they never leak into the URL string. let (workspaceOpt, argsAfterWorkspace) = parseOption(subArgs, name: "--workspace") let (windowOpt, urlArgs) = parseOption(argsAfterWorkspace, name: "--window") let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) let respectExternalOpenRules: Bool = { guard let raw = ProcessInfo.processInfo.environment["CMUX_RESPECT_EXTERNAL_OPEN_RULES"] else { return false } switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "1", "true", "yes", "on": return true default: return false } }() if surfaceRaw != nil, subcommand == "open" { // Treat `browser <surface> open <url>` as navigate for agent-browser ergonomics. let sid = try requireSurface() guard !url.isEmpty else { throw CLIError(message: "browser <surface> open requires a URL") } let payload = try client.sendV2(method: "browser.navigate", params: ["surface_id": sid, "url": url]) output(payload, fallback: "OK") return } var params: [String: Any] = [:] if !url.isEmpty { params["url"] = url } if let sourceSurface = try normalizeSurfaceHandle(surfaceRaw, client: client) { params["surface_id"] = sourceSurface } let workspaceRaw = workspaceOpt ?? (windowOpt == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) if let workspaceRaw { if let workspace = try normalizeWorkspaceHandle(workspaceRaw, client: client) { params["workspace_id"] = workspace } } if respectExternalOpenRules { params["respect_external_open_rules"] = true } if let windowRaw = windowOpt { if let window = try normalizeWindowHandle(windowRaw, client: client) { params["window_id"] = window } } let payload = try client.sendV2(method: "browser.open_split", params: params) let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown" let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown" let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse" output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)") return } if subcommand == "goto" || subcommand == "navigate" { let sid = try requireSurface() var urlArgs = subArgs let snapshotAfter = urlArgs.last == "--snapshot-after" if snapshotAfter { urlArgs.removeLast() } let url = urlArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !url.isEmpty else { throw CLIError(message: "browser \(subcommand) requires a URL") } var params: [String: Any] = ["surface_id": sid, "url": url] if snapshotAfter { params["snapshot_after"] = true } let payload = try client.sendV2(method: "browser.navigate", params: params) output(payload, fallback: "OK") return } if subcommand == "back" || subcommand == "forward" || subcommand == "reload" { let sid = try requireSurface() let methodMap: [String: String] = [ "back": "browser.back", "forward": "browser.forward", "reload": "browser.reload", ] var params: [String: Any] = ["surface_id": sid] if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } if subcommand == "url" || subcommand == "get-url" { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid]) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["url"] as? String) ?? "") } return } if ["focus-webview", "focus_webview"].contains(subcommand) { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.focus_webview", params: ["surface_id": sid]) output(payload, fallback: "OK") return } if ["is-webview-focused", "is_webview_focused"].contains(subcommand) { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid]) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["focused"] as? Bool) == true ? "true" : "false") } return } if subcommand == "snapshot" { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let (depthOpt, _) = parseOption(rem1, name: "--max-depth") var params: [String: Any] = ["surface_id": sid] if let selectorOpt { params["selector"] = selectorOpt } if hasFlag(subArgs, name: "--interactive") || hasFlag(subArgs, name: "-i") { params["interactive"] = true } if hasFlag(subArgs, name: "--cursor") { params["cursor"] = true } if hasFlag(subArgs, name: "--compact") { params["compact"] = true } if let depthOpt { guard let depth = Int(depthOpt), depth >= 0 else { throw CLIError(message: "--max-depth must be a non-negative integer") } params["max_depth"] = depth } let payload = try client.sendV2(method: "browser.snapshot", params: params) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print(displaySnapshotText(payload)) } return } if subcommand == "eval" { let sid = try requireSurface() let script = optionValue(subArgs, name: "--script") ?? subArgs.joined(separator: " ") let trimmed = script.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw CLIError(message: "browser eval requires a script") } let payload = try client.sendV2(method: "browser.eval", params: ["surface_id": sid, "script": trimmed]) let fallback: String if let value = payload["value"] { fallback = displayBrowserValue(value) } else { fallback = "OK" } output(payload, fallback: fallback) return } if subcommand == "wait" { let sid = try requireSurface() var params: [String: Any] = ["surface_id": sid] let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let (textOpt, rem2) = parseOption(rem1, name: "--text") let (urlContainsOptA, rem3) = parseOption(rem2, name: "--url-contains") let (urlContainsOptB, rem4) = parseOption(rem3, name: "--url") let (loadStateOpt, rem5) = parseOption(rem4, name: "--load-state") let (functionOpt, rem6) = parseOption(rem5, name: "--function") let (timeoutOptMs, rem7) = parseOption(rem6, name: "--timeout-ms") let (timeoutOptSec, rem8) = parseOption(rem7, name: "--timeout") if let selector = selectorOpt ?? rem8.first { params["selector"] = selector } if let textOpt { params["text_contains"] = textOpt } if let urlContains = urlContainsOptA ?? urlContainsOptB { params["url_contains"] = urlContains } if let loadStateOpt { params["load_state"] = loadStateOpt } if let functionOpt { params["function"] = functionOpt } if let timeoutOptMs { guard let ms = Int(timeoutOptMs) else { throw CLIError(message: "--timeout-ms must be an integer") } params["timeout_ms"] = ms } else if let timeoutOptSec { guard let seconds = Double(timeoutOptSec) else { throw CLIError(message: "--timeout must be a number") } params["timeout_ms"] = max(1, Int(seconds * 1000.0)) } let payload = try client.sendV2(method: "browser.wait", params: params) output(payload, fallback: "OK") return } if ["click", "dblclick", "hover", "focus", "check", "uncheck", "scrollintoview", "scrollinto", "scroll-into-view"].contains(subcommand) { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let selector = selectorOpt ?? rem1.first guard let selector else { throw CLIError(message: "browser \(subcommand) requires a selector") } let methodMap: [String: String] = [ "click": "browser.click", "dblclick": "browser.dblclick", "hover": "browser.hover", "focus": "browser.focus", "check": "browser.check", "uncheck": "browser.uncheck", "scrollintoview": "browser.scroll_into_view", "scrollinto": "browser.scroll_into_view", "scroll-into-view": "browser.scroll_into_view", ] var params: [String: Any] = ["surface_id": sid, "selector": selector] if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } if ["type", "fill"].contains(subcommand) { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let (textOpt, rem2) = parseOption(rem1, name: "--text") let selector = selectorOpt ?? rem2.first guard let selector else { throw CLIError(message: "browser \(subcommand) requires a selector") } let positional = selectorOpt != nil ? rem2 : Array(rem2.dropFirst()) let hasExplicitText = textOpt != nil || !positional.isEmpty let text: String if let textOpt { text = textOpt } else { text = positional.joined(separator: " ") } if subcommand == "type" { guard hasExplicitText, !text.isEmpty else { throw CLIError(message: "browser type requires text") } } let method = (subcommand == "type") ? "browser.type" : "browser.fill" var params: [String: Any] = ["surface_id": sid, "selector": selector, "text": text] if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: method, params: params) output(payload, fallback: "OK") return } if ["press", "key", "keydown", "keyup"].contains(subcommand) { let sid = try requireSurface() let (keyOpt, rem1) = parseOption(subArgs, name: "--key") let key = keyOpt ?? rem1.first guard let key else { throw CLIError(message: "browser \(subcommand) requires a key") } let methodMap: [String: String] = [ "press": "browser.press", "key": "browser.press", "keydown": "browser.keydown", "keyup": "browser.keyup", ] var params: [String: Any] = ["surface_id": sid, "key": key] if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: methodMap[subcommand]!, params: params) output(payload, fallback: "OK") return } if subcommand == "select" { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let (valueOpt, rem2) = parseOption(rem1, name: "--value") let selector = selectorOpt ?? rem2.first guard let selector else { throw CLIError(message: "browser select requires a selector") } let value = valueOpt ?? (selectorOpt != nil ? rem2.first : rem2.dropFirst().first) guard let value else { throw CLIError(message: "browser select requires a value") } var params: [String: Any] = ["surface_id": sid, "selector": selector, "value": value] if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: "browser.select", params: params) output(payload, fallback: "OK") return } if subcommand == "scroll" { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let (dxOpt, rem2) = parseOption(rem1, name: "--dx") let (dyOpt, rem3) = parseOption(rem2, name: "--dy") var params: [String: Any] = ["surface_id": sid] if let selectorOpt { params["selector"] = selectorOpt } if let dxOpt { guard let dx = Int(dxOpt) else { throw CLIError(message: "--dx must be an integer") } params["dx"] = dx } if let dyOpt { guard let dy = Int(dyOpt) else { throw CLIError(message: "--dy must be an integer") } params["dy"] = dy } else if let first = rem3.first, let dy = Int(first) { params["dy"] = dy } if hasFlag(subArgs, name: "--snapshot-after") { params["snapshot_after"] = true } let payload = try client.sendV2(method: "browser.scroll", params: params) output(payload, fallback: "OK") return } if subcommand == "screenshot" { let sid = try requireSurface() let (outPathOpt, _) = parseOption(subArgs, name: "--out") let localJSONOutput = hasFlag(subArgs, name: "--json") let outputAsJSON = effectiveJSONOutput || localJSONOutput var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) func fileURL(fromPath rawPath: String) -> URL { let resolvedPath = resolvePath(rawPath) return URL(fileURLWithPath: resolvedPath).standardizedFileURL } func writeScreenshot(_ data: Data, to destinationURL: URL) throws { try FileManager.default.createDirectory( at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try data.write(to: destinationURL, options: .atomic) } func hasText(_ value: String?) -> Bool { guard let value else { return false } return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var screenshotPath = payload["path"] as? String var screenshotURL = payload["url"] as? String func syncScreenshotLocationFields() { if !hasText(screenshotPath), let rawURL = screenshotURL, let fileURL = URL(string: rawURL), fileURL.isFileURL, !fileURL.path.isEmpty { screenshotPath = fileURL.path } if !hasText(screenshotURL), let screenshotPath, hasText(screenshotPath) { screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString } if let screenshotPath, hasText(screenshotPath) { payload["path"] = screenshotPath } if let screenshotURL, hasText(screenshotURL) { payload["url"] = screenshotURL } } func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool { if let sourcePath = screenshotPath, hasText(sourcePath) { let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL do { if sourceURL.path != destinationURL.path { try FileManager.default.createDirectory( at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true ) try? FileManager.default.removeItem(at: destinationURL) try FileManager.default.copyItem(at: sourceURL, to: destinationURL) } return true } catch { if payload["png_base64"] == nil { if allowFailure { return false } throw error } } } if let b64 = payload["png_base64"] as? String, let data = Data(base64Encoded: b64) { do { try writeScreenshot(data, to: destinationURL) return true } catch { if allowFailure { return false } throw error } } return false } if let outPathOpt { let outputURL = fileURL(fromPath: outPathOpt) guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else { throw CLIError(message: "browser screenshot missing image data") } screenshotPath = outputURL.path screenshotURL = outputURL.absoluteString payload["path"] = screenshotPath payload["url"] = screenshotURL } else { syncScreenshotLocationFields() if !hasText(screenshotPath) && !hasText(screenshotURL) { let outputDir = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true) if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil { bestEffortPruneTemporaryFiles(in: outputDir) let timestampMs = Int(Date().timeIntervalSince1970 * 1000) let safeSid = sanitizedFilenameComponent(sid) let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png" let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false) if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true { screenshotPath = outputURL.path screenshotURL = outputURL.absoluteString payload["path"] = screenshotPath payload["url"] = screenshotURL } } } } if outputAsJSON { let formattedPayload = formatIDs(payload, mode: effectiveIDFormat) if var outputPayload = formattedPayload as? [String: Any] { if hasText(screenshotPath) || hasText(screenshotURL) { outputPayload.removeValue(forKey: "png_base64") } print(jsonString(outputPayload)) } else { print(jsonString(formattedPayload)) } } else if let outPathOpt { print("OK \(outPathOpt)") } else if let screenshotURL, hasText(screenshotURL) { print("OK \(screenshotURL)") } else if let screenshotPath, hasText(screenshotPath) { print("OK \(screenshotPath)") } else { print("OK") } return } if subcommand == "get" { let sid = try requireSurface() guard let getVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser get requires a subcommand") } let getArgs = Array(subArgs.dropFirst()) switch getVerb { case "url": let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid]) output(payload, fallback: (payload["url"] as? String) ?? "") case "title": let payload = try client.sendV2(method: "browser.get.title", params: ["surface_id": sid]) output(payload, fallback: (payload["title"] as? String) ?? "") case "text", "html", "value", "count", "box", "styles", "attr": let (selectorOpt, rem1) = parseOption(getArgs, name: "--selector") let selector = selectorOpt ?? rem1.first if getVerb != "title" && getVerb != "url" { guard selector != nil else { throw CLIError(message: "browser get \(getVerb) requires a selector") } } var params: [String: Any] = ["surface_id": sid] if let selector { params["selector"] = selector } if getVerb == "attr" { let (attrOpt, rem2) = parseOption(rem1, name: "--attr") let attr = attrOpt ?? rem2.dropFirst().first guard let attr else { throw CLIError(message: "browser get attr requires --attr <name>") } params["attr"] = attr } if getVerb == "styles" { let (propOpt, _) = parseOption(rem1, name: "--property") if let propOpt { params["property"] = propOpt } } let methodMap: [String: String] = [ "text": "browser.get.text", "html": "browser.get.html", "value": "browser.get.value", "attr": "browser.get.attr", "count": "browser.get.count", "box": "browser.get.box", "styles": "browser.get.styles", ] let payload = try client.sendV2(method: methodMap[getVerb]!, params: params) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { if let str = value as? String { print(str) } else { print(jsonString(value)) } } else if let count = payload["count"] { print("\(count)") } else { print("OK") } default: throw CLIError(message: "Unsupported browser get subcommand: \(getVerb)") } return } if subcommand == "is" { let sid = try requireSurface() guard let isVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser is requires a subcommand") } let isArgs = Array(subArgs.dropFirst()) let (selectorOpt, rem1) = parseOption(isArgs, name: "--selector") let selector = selectorOpt ?? rem1.first guard let selector else { throw CLIError(message: "browser is \(isVerb) requires a selector") } let methodMap: [String: String] = [ "visible": "browser.is.visible", "enabled": "browser.is.enabled", "checked": "browser.is.checked", ] guard let method = methodMap[isVerb] else { throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector]) if effectiveJSONOutput { print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { print("\(value)") } else { print("false") } return } if subcommand == "find" { let sid = try requireSurface() guard let locator = subArgs.first?.lowercased() else { throw CLIError(message: "browser find requires a locator (role|text|label|placeholder|alt|title|testid|first|last|nth)") } let locatorArgs = Array(subArgs.dropFirst()) var params: [String: Any] = ["surface_id": sid] let method: String switch locator { case "role": let (nameOpt, rem1) = parseOption(locatorArgs, name: "--name") let candidates = nonFlagArgs(rem1) guard let role = candidates.first else { throw CLIError(message: "browser find role requires <role>") } params["role"] = role if let nameOpt { params["name"] = nameOpt } if hasFlag(locatorArgs, name: "--exact") { params["exact"] = true } method = "browser.find.role" case "text", "label", "placeholder", "alt", "title", "testid": let keyMap: [String: String] = [ "text": "text", "label": "label", "placeholder": "placeholder", "alt": "alt", "title": "title", "testid": "testid", ] let candidates = nonFlagArgs(locatorArgs) guard let value = candidates.first else { throw CLIError(message: "browser find \(locator) requires a value") } params[keyMap[locator]!] = value if hasFlag(locatorArgs, name: "--exact") { params["exact"] = true } method = "browser.find.\(locator)" case "first", "last": let (selectorOpt, rem1) = parseOption(locatorArgs, name: "--selector") let candidates = nonFlagArgs(rem1) guard let selector = selectorOpt ?? candidates.first else { throw CLIError(message: "browser find \(locator) requires a selector") } params["selector"] = selector method = "browser.find.\(locator)" case "nth": let (indexOpt, rem1) = parseOption(locatorArgs, name: "--index") let (selectorOpt, rem2) = parseOption(rem1, name: "--selector") let candidates = nonFlagArgs(rem2) let indexRaw = indexOpt ?? candidates.first guard let indexRaw, let index = Int(indexRaw) else { throw CLIError(message: "browser find nth requires an integer index") } let selector = selectorOpt ?? (candidates.count >= 2 ? candidates[1] : nil) guard let selector else { throw CLIError(message: "browser find nth requires a selector") } params["index"] = index params["selector"] = selector method = "browser.find.nth" default: throw CLIError(message: "Unsupported browser find locator: \(locator)") } let payload = try client.sendV2(method: method, params: params) output(payload, fallback: "OK") return } if subcommand == "frame" { let sid = try requireSurface() guard let frameVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser frame requires <selector|main>") } if frameVerb == "main" { let payload = try client.sendV2(method: "browser.frame.main", params: ["surface_id": sid]) output(payload, fallback: "OK") return } let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let selector = selectorOpt ?? nonFlagArgs(rem1).first guard let selector else { throw CLIError(message: "browser frame requires a selector or 'main'") } let payload = try client.sendV2(method: "browser.frame.select", params: ["surface_id": sid, "selector": selector]) output(payload, fallback: "OK") return } if subcommand == "dialog" { let sid = try requireSurface() guard let dialogVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser dialog requires <accept|dismiss> [text]") } let remainder = Array(subArgs.dropFirst()) switch dialogVerb { case "accept": let text = remainder.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) var params: [String: Any] = ["surface_id": sid] if !text.isEmpty { params["text"] = text } let payload = try client.sendV2(method: "browser.dialog.accept", params: params) output(payload, fallback: "OK") case "dismiss": let payload = try client.sendV2(method: "browser.dialog.dismiss", params: ["surface_id": sid]) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser dialog subcommand: \(dialogVerb)") } return } if subcommand == "download" { let sid = try requireSurface() let argsForDownload: [String] if subArgs.first?.lowercased() == "wait" { argsForDownload = Array(subArgs.dropFirst()) } else { argsForDownload = subArgs } let (pathOpt, rem1) = parseOption(argsForDownload, name: "--path") let (timeoutMsOpt, rem2) = parseOption(rem1, name: "--timeout-ms") let (timeoutSecOpt, rem3) = parseOption(rem2, name: "--timeout") var params: [String: Any] = ["surface_id": sid] if let path = pathOpt ?? nonFlagArgs(rem3).first { params["path"] = path } if let timeoutMsOpt { guard let timeoutMs = Int(timeoutMsOpt) else { throw CLIError(message: "--timeout-ms must be an integer") } params["timeout_ms"] = timeoutMs } else if let timeoutSecOpt { guard let seconds = Double(timeoutSecOpt) else { throw CLIError(message: "--timeout must be a number") } params["timeout_ms"] = max(1, Int(seconds * 1000.0)) } let payload = try client.sendV2(method: "browser.download.wait", params: params) output(payload, fallback: "OK") return } if subcommand == "cookies" { let sid = try requireSurface() let cookieVerb = subArgs.first?.lowercased() ?? "get" let cookieArgs = subArgs.first != nil ? Array(subArgs.dropFirst()) : [] let (nameOpt, rem1) = parseOption(cookieArgs, name: "--name") let (valueOpt, rem2) = parseOption(rem1, name: "--value") let (urlOpt, rem3) = parseOption(rem2, name: "--url") let (domainOpt, rem4) = parseOption(rem3, name: "--domain") let (pathOpt, rem5) = parseOption(rem4, name: "--path") let (expiresOpt, _) = parseOption(rem5, name: "--expires") var params: [String: Any] = ["surface_id": sid] if let nameOpt { params["name"] = nameOpt } if let valueOpt { params["value"] = valueOpt } if let urlOpt { params["url"] = urlOpt } if let domainOpt { params["domain"] = domainOpt } if let pathOpt { params["path"] = pathOpt } if hasFlag(cookieArgs, name: "--secure") { params["secure"] = true } if hasFlag(cookieArgs, name: "--all") { params["all"] = true } if let expiresOpt { guard let expires = Int(expiresOpt) else { throw CLIError(message: "--expires must be an integer Unix timestamp") } params["expires"] = expires } switch cookieVerb { case "get": let payload = try client.sendV2(method: "browser.cookies.get", params: params) output(payload, fallback: "OK") case "set": var setParams = params let positional = nonFlagArgs(cookieArgs) if setParams["name"] == nil, positional.count >= 1 { setParams["name"] = positional[0] } if setParams["value"] == nil, positional.count >= 2 { setParams["value"] = positional[1] } guard setParams["name"] != nil, setParams["value"] != nil else { throw CLIError(message: "browser cookies set requires <name> <value> (or --name/--value)") } let payload = try client.sendV2(method: "browser.cookies.set", params: setParams) output(payload, fallback: "OK") case "clear": let payload = try client.sendV2(method: "browser.cookies.clear", params: params) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser cookies subcommand: \(cookieVerb)") } return } if subcommand == "storage" { let sid = try requireSurface() let storageArgs = subArgs let storageType = storageArgs.first?.lowercased() ?? "local" guard storageType == "local" || storageType == "session" else { throw CLIError(message: "browser storage requires type: local|session") } let op = storageArgs.count >= 2 ? storageArgs[1].lowercased() : "get" let rest = storageArgs.count > 2 ? Array(storageArgs.dropFirst(2)) : [] let positional = nonFlagArgs(rest) var params: [String: Any] = ["surface_id": sid, "type": storageType] switch op { case "get": if let key = positional.first { params["key"] = key } let payload = try client.sendV2(method: "browser.storage.get", params: params) output(payload, fallback: "OK") case "set": guard positional.count >= 2 else { throw CLIError(message: "browser storage \(storageType) set requires <key> <value>") } params["key"] = positional[0] params["value"] = positional[1] let payload = try client.sendV2(method: "browser.storage.set", params: params) output(payload, fallback: "OK") case "clear": let payload = try client.sendV2(method: "browser.storage.clear", params: params) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser storage subcommand: \(op)") } return } if subcommand == "tab" { let sid = try requireSurface() let first = subArgs.first?.lowercased() let tabVerb: String let tabArgs: [String] if let first, ["new", "list", "close", "switch"].contains(first) { tabVerb = first tabArgs = Array(subArgs.dropFirst()) } else if let first, Int(first) != nil { tabVerb = "switch" tabArgs = subArgs } else { tabVerb = "list" tabArgs = subArgs } switch tabVerb { case "list": let payload = try client.sendV2(method: "browser.tab.list", params: ["surface_id": sid]) output(payload, fallback: "OK") case "new": var params: [String: Any] = ["surface_id": sid] let url = tabArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) if !url.isEmpty { params["url"] = url } let payload = try client.sendV2(method: "browser.tab.new", params: params) output(payload, fallback: "OK") case "switch", "close": let method = (tabVerb == "switch") ? "browser.tab.switch" : "browser.tab.close" var params: [String: Any] = ["surface_id": sid] let target = tabArgs.first if let target { if let index = Int(target) { params["index"] = index } else { params["target_surface_id"] = target } } let payload = try client.sendV2(method: method, params: params) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser tab subcommand: \(tabVerb)") } return } if subcommand == "console" { let sid = try requireSurface() let consoleVerb = subArgs.first?.lowercased() ?? "list" let method = (consoleVerb == "clear") ? "browser.console.clear" : "browser.console.list" if consoleVerb != "list" && consoleVerb != "clear" { throw CLIError(message: "Unsupported browser console subcommand: \(consoleVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) if effectiveJSONOutput || consoleVerb == "clear" { output(payload, fallback: "OK") } else { print(displayBrowserLogItems(payload["entries"]) ?? "No console entries") } return } if subcommand == "errors" { let sid = try requireSurface() let errorsVerb = subArgs.first?.lowercased() ?? "list" var params: [String: Any] = ["surface_id": sid] if errorsVerb == "clear" { params["clear"] = true } else if errorsVerb != "list" { throw CLIError(message: "Unsupported browser errors subcommand: \(errorsVerb)") } let payload = try client.sendV2(method: "browser.errors.list", params: params) if effectiveJSONOutput || errorsVerb == "clear" { output(payload, fallback: "OK") } else { print(displayBrowserLogItems(payload["errors"]) ?? "No browser errors") } return } if subcommand == "highlight" { let sid = try requireSurface() let (selectorOpt, rem1) = parseOption(subArgs, name: "--selector") let selector = selectorOpt ?? nonFlagArgs(rem1).first guard let selector else { throw CLIError(message: "browser highlight requires a selector") } let payload = try client.sendV2(method: "browser.highlight", params: ["surface_id": sid, "selector": selector]) output(payload, fallback: "OK") return } if subcommand == "state" { let sid = try requireSurface() guard let stateVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser state requires save|load <path>") } guard subArgs.count >= 2 else { throw CLIError(message: "browser state \(stateVerb) requires a file path") } let path = subArgs[1] let method: String switch stateVerb { case "save": method = "browser.state.save" case "load": method = "browser.state.load" default: throw CLIError(message: "Unsupported browser state subcommand: \(stateVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid, "path": path]) output(payload, fallback: "OK") return } if subcommand == "addinitscript" || subcommand == "addscript" || subcommand == "addstyle" { let sid = try requireSurface() let field = (subcommand == "addstyle") ? "css" : "script" let flag = (subcommand == "addstyle") ? "--css" : "--script" let (scriptOpt, rem1) = parseOption(subArgs, name: flag) let content = (scriptOpt ?? rem1.joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { throw CLIError(message: "browser \(subcommand) requires content") } let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid, field: content]) output(payload, fallback: "OK") return } if subcommand == "viewport" { let sid = try requireSurface() guard subArgs.count >= 2, let width = Int(subArgs[0]), let height = Int(subArgs[1]) else { throw CLIError(message: "browser viewport requires: <width> <height>") } let payload = try client.sendV2(method: "browser.viewport.set", params: ["surface_id": sid, "width": width, "height": height]) output(payload, fallback: "OK") return } if subcommand == "geolocation" || subcommand == "geo" { let sid = try requireSurface() guard subArgs.count >= 2, let latitude = Double(subArgs[0]), let longitude = Double(subArgs[1]) else { throw CLIError(message: "browser geolocation requires: <latitude> <longitude>") } let payload = try client.sendV2(method: "browser.geolocation.set", params: ["surface_id": sid, "latitude": latitude, "longitude": longitude]) output(payload, fallback: "OK") return } if subcommand == "offline" { let sid = try requireSurface() guard let raw = subArgs.first, let enabled = parseBoolString(raw) else { throw CLIError(message: "browser offline requires true|false") } let payload = try client.sendV2(method: "browser.offline.set", params: ["surface_id": sid, "enabled": enabled]) output(payload, fallback: "OK") return } if subcommand == "trace" { let sid = try requireSurface() guard let traceVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser trace requires start|stop") } let method: String switch traceVerb { case "start": method = "browser.trace.start" case "stop": method = "browser.trace.stop" default: throw CLIError(message: "Unsupported browser trace subcommand: \(traceVerb)") } var params: [String: Any] = ["surface_id": sid] if subArgs.count >= 2 { params["path"] = subArgs[1] } let payload = try client.sendV2(method: method, params: params) output(payload, fallback: "OK") return } if subcommand == "network" { let sid = try requireSurface() guard let networkVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser network requires route|unroute|requests") } let networkArgs = Array(subArgs.dropFirst()) switch networkVerb { case "route": guard let pattern = networkArgs.first else { throw CLIError(message: "browser network route requires a URL/pattern") } var params: [String: Any] = ["surface_id": sid, "url": pattern] if hasFlag(networkArgs, name: "--abort") { params["abort"] = true } let (bodyOpt, _) = parseOption(networkArgs, name: "--body") if let bodyOpt { params["body"] = bodyOpt } let payload = try client.sendV2(method: "browser.network.route", params: params) output(payload, fallback: "OK") case "unroute": guard let pattern = networkArgs.first else { throw CLIError(message: "browser network unroute requires a URL/pattern") } let payload = try client.sendV2(method: "browser.network.unroute", params: ["surface_id": sid, "url": pattern]) output(payload, fallback: "OK") case "requests": let payload = try client.sendV2(method: "browser.network.requests", params: ["surface_id": sid]) output(payload, fallback: "OK") default: throw CLIError(message: "Unsupported browser network subcommand: \(networkVerb)") } return } if subcommand == "screencast" { let sid = try requireSurface() guard let castVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser screencast requires start|stop") } let method: String switch castVerb { case "start": method = "browser.screencast.start" case "stop": method = "browser.screencast.stop" default: throw CLIError(message: "Unsupported browser screencast subcommand: \(castVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid]) output(payload, fallback: "OK") return } if subcommand == "input" { let sid = try requireSurface() guard let inputVerb = subArgs.first?.lowercased() else { throw CLIError(message: "browser input requires mouse|keyboard|touch") } let remainder = Array(subArgs.dropFirst()) let method: String switch inputVerb { case "mouse": method = "browser.input_mouse" case "keyboard": method = "browser.input_keyboard" case "touch": method = "browser.input_touch" default: throw CLIError(message: "Unsupported browser input subcommand: \(inputVerb)") } var params: [String: Any] = ["surface_id": sid] if !remainder.isEmpty { params["args"] = remainder } let payload = try client.sendV2(method: method, params: params) output(payload, fallback: "OK") return } if ["input_mouse", "input_keyboard", "input_touch"].contains(subcommand) { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.\(subcommand)", params: ["surface_id": sid]) output(payload, fallback: "OK") return } throw CLIError(message: "Unsupported browser subcommand: \(subcommand)") } private func parseWindows(_ response: String) -> [WindowInfo] { guard response != "No windows" else { return [] } return response .split(separator: "\n") .compactMap { line in let raw = String(line) let key = raw.hasPrefix("*") let cleaned = raw.trimmingCharacters(in: CharacterSet(charactersIn: "* ")) let parts = cleaned.split(separator: " ", omittingEmptySubsequences: true).map(String.init) guard parts.count >= 2 else { return nil } let indexText = parts[0].replacingOccurrences(of: ":", with: "") guard let index = Int(indexText) else { return nil } let id = parts[1] var selectedWorkspaceId: String? var workspaceCount: Int = 0 for token in parts.dropFirst(2) { if token.hasPrefix("selected_workspace=") { let v = token.replacingOccurrences(of: "selected_workspace=", with: "") selectedWorkspaceId = (v == "none") ? nil : v } else if token.hasPrefix("workspaces=") { let v = token.replacingOccurrences(of: "workspaces=", with: "") workspaceCount = Int(v) ?? 0 } } return WindowInfo( index: index, id: id, key: key, selectedWorkspaceId: selectedWorkspaceId, workspaceCount: workspaceCount ) } } private func parseNotifications(_ response: String) -> [NotificationInfo] { guard response != "No notifications" else { return [] } return response .split(separator: "\n") .compactMap { line in let raw = String(line) let parts = raw.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) guard parts.count == 2 else { return nil } let payload = parts[1].split(separator: "|", maxSplits: 6, omittingEmptySubsequences: false) guard payload.count >= 7 else { return nil } let notifId = String(payload[0]) let workspaceId = String(payload[1]) let surfaceRaw = String(payload[2]) let surfaceId = surfaceRaw == "none" ? nil : surfaceRaw let readText = String(payload[3]) let title = String(payload[4]) let subtitle = String(payload[5]) let body = String(payload[6]) return NotificationInfo( id: notifId, workspaceId: workspaceId, surfaceId: surfaceId, isRead: readText == "read", title: title, subtitle: subtitle, body: body ) } } private func resolveWorkspaceId(_ raw: String?, client: SocketClient) throws -> String { if let raw, isUUID(raw) { return raw } if let raw, isHandleRef(raw) { // Resolve ref to UUID — search across all windows let windows = try client.sendV2(method: "window.list") let windowList = windows["windows"] as? [[String: Any]] ?? [] for window in windowList { guard let windowId = window["id"] as? String else { continue } let listed = try client.sendV2(method: "workspace.list", params: ["window_id": windowId]) let items = listed["workspaces"] as? [[String: Any]] ?? [] for item in items where (item["ref"] as? String) == raw { if let id = item["id"] as? String { return id } } } throw CLIError(message: "Workspace ref not found: \(raw)") } if let raw, let index = Int(raw) { let listed = try client.sendV2(method: "workspace.list") let items = listed["workspaces"] as? [[String: Any]] ?? [] for item in items where intFromAny(item["index"]) == index { if let id = item["id"] as? String { return id } } throw CLIError(message: "Workspace index not found") } let current = try client.sendV2(method: "workspace.current") if let wsId = current["workspace_id"] as? String { return wsId } throw CLIError(message: "No workspace selected") } private func resolveSurfaceId(_ raw: String?, workspaceId: String, client: SocketClient) throws -> String { if let raw, isUUID(raw) { return raw } if let raw, isHandleRef(raw) { let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) let items = listed["surfaces"] as? [[String: Any]] ?? [] for item in items where (item["ref"] as? String) == raw { if let id = item["id"] as? String { return id } } throw CLIError(message: "Surface ref not found: \(raw)") } let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) let items = listed["surfaces"] as? [[String: Any]] ?? [] if let raw, let index = Int(raw) { for item in items where intFromAny(item["index"]) == index { if let id = item["id"] as? String { return id } } throw CLIError(message: "Surface index not found") } if let focused = items.first(where: { ($0["focused"] as? Bool) == true }) { if let id = focused["id"] as? String { return id } } throw CLIError(message: "Unable to resolve surface ID") } /// Return the help/usage text for a subcommand, or nil if the command is unknown. private func subcommandUsage(_ command: String) -> String? { switch command { case "ping": return """ Usage: cmux ping Check connectivity to the cmux socket server. """ case "capabilities": return """ Usage: cmux capabilities Print server capabilities as JSON. """ case "help": return """ Usage: cmux help Show top-level CLI usage and command list. """ case "welcome": return """ Usage: cmux welcome Show a welcome screen with the cmux logo and useful shortcuts. Auto-runs once on first launch. """ case "shortcuts": return """ Usage: cmux shortcuts Open the Settings window to Keyboard Shortcuts. """ case "feedback": return """ Usage: cmux feedback cmux feedback --email <email> --body <text> [--image <path> ...] Without args, open the Send Feedback modal in the running app. With args, submit feedback through the app using the same feedback pipeline as the modal. Flags: --email <email> Contact email for follow-up --body <text> Feedback body --image <path> Attach an image file, repeat for multiple images Coding agents: Double check with the end user before sending anything. Review the message and attachments for secrets, private code, credentials, tokens, and other sensitive information first. """ case "themes": return """ Usage: cmux themes cmux themes list cmux themes set <theme> cmux themes set --light <theme> [--dark <theme>] cmux themes set --dark <theme> [--light <theme>] cmux themes clear When run in a TTY, `cmux themes` opens an interactive theme picker with live app preview. Use `cmux themes list` for a plain listing. The picker previews the selected theme across the running cmux app and lets you apply it to the light theme, dark theme, or both defaults. Commands: list List available themes and mark the current light/dark defaults set <theme> Set the same theme for both light and dark appearance set --light <theme> Set the light appearance theme set --dark <theme> Set the dark appearance theme clear Remove the cmux theme override and fall back to other config Examples: cmux themes cmux themes list cmux themes set "Catppuccin Mocha" cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" cmux themes clear """ case "claude-teams": return String(localized: "cli.claude-teams.usage", defaultValue: """ Usage: cmux claude-teams [claude-args...] Launch Claude Code with agent teams enabled. This command: - defaults Claude teammate mode to auto - sets a tmux-like environment so Claude auto mode uses cmux splits - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 - prepends a private tmux shim to PATH - forwards all remaining arguments to claude The tmux shim translates supported tmux window/pane commands into cmux workspace and split operations in the current cmux session. Examples: cmux claude-teams cmux claude-teams --continue cmux claude-teams --model sonnet """) case "omo": return String(localized: "cli.omo.usage", defaultValue: """ Usage: cmux omo [opencode-args...] Launch OpenCode with oh-my-openagent in a cmux-aware environment. oh-my-openagent orchestrates multiple AI models as specialized agents in parallel. This command sets up a tmux shim so agent panes become native cmux splits with sidebar metadata and notifications. This command: - sets a tmux-like environment so oh-my-openagent uses cmux splits - prepends a private tmux shim to PATH - forwards all remaining arguments to opencode The tmux shim translates tmux window/pane commands into cmux workspace and split operations in the current cmux session. Examples: cmux omo cmux omo --continue cmux omo --model claude-sonnet-4-6 """) case "identify": return """ Usage: cmux identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] Print server identity and caller context details. Flags: --workspace <id|ref|index> Caller workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref|index> Caller surface context (default: $CMUX_SURFACE_ID) --no-caller Omit caller context from the request """ case "list-windows": return """ Usage: cmux list-windows List open windows. """ case "current-window": return """ Usage: cmux current-window Print the currently selected window ID. """ case "new-window": return """ Usage: cmux new-window Create a new window. Example: cmux new-window """ case "focus-window": return """ Usage: cmux focus-window --window <id|ref|index> Focus (bring to front) the specified window. Flags: --window <id|ref|index> Window to focus (required) Example: cmux focus-window --window 0 cmux focus-window --window window:1 """ case "close-window": return """ Usage: cmux close-window --window <id|ref|index> Close the specified window. Flags: --window <id|ref|index> Window to close (required) Example: cmux close-window --window 0 cmux close-window --window window:1 """ case "move-workspace-to-window": return """ Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index> Move a workspace to a different window. Flags: --workspace <id|ref|index> Workspace to move (required) --window <id|ref|index> Target window (required) Example: cmux move-workspace-to-window --workspace workspace:2 --window window:1 """ case "move-surface": return """ Usage: cmux move-surface [--surface <id|ref|index> | <id|ref|index>] [flags] Move a surface to a different pane, workspace, or window. Flags: --surface <id|ref|index> Surface to move (required unless passed positionally) --pane <id|ref|index> Target pane --workspace <id|ref|index> Target workspace --window <id|ref|index> Target window --before <id|ref|index> Place before this surface --before-surface <id|ref|index> Alias for --before --after <id|ref|index> Place after this surface --after-surface <id|ref|index> Alias for --after --index <n> Place at this index --focus <true|false> Focus the surface after moving Example: cmux move-surface --surface surface:1 --workspace workspace:2 cmux move-surface surface:1 --pane pane:2 --index 0 """ case "reorder-surface": return """ Usage: cmux reorder-surface [--surface <id|ref|index> | <id|ref|index>] [flags] Reorder a surface within its pane. Flags: --surface <id|ref|index> Surface to reorder (required unless passed positionally) --workspace <id|ref|index> Workspace context --before <id|ref|index> Place before this surface --before-surface <id|ref|index> Alias for --before --after <id|ref|index> Place after this surface --after-surface <id|ref|index> Alias for --after --index <n> Place at this index Example: cmux reorder-surface --surface surface:1 --index 0 cmux reorder-surface --surface surface:3 --after surface:1 """ case "reorder-workspace": return """ Usage: cmux reorder-workspace [--workspace <id|ref|index> | <id|ref|index>] [flags] Reorder a workspace within its window. Flags: --workspace <id|ref|index> Workspace to reorder (required unless passed positionally) --index <n> Place at this index --before <id|ref|index> Place before this workspace --before-workspace <id|ref|index> Alias for --before --after <id|ref|index> Place after this workspace --after-workspace <id|ref|index> Alias for --after --window <id|ref|index> Window context Example: cmux reorder-workspace --workspace workspace:2 --index 0 cmux reorder-workspace --workspace workspace:3 --after workspace:1 """ case "workspace-action": return """ Usage: cmux workspace-action --action <name> [flags] Perform workspace context-menu actions from CLI/socket. Actions: pin | unpin rename | clear-name move-up | move-down | move-top close-others | close-above | close-below mark-read | mark-unread set-color | clear-color Flags: --action <name> Action name (required if not positional) --workspace <id|ref|index> Target workspace (default: current/$CMUX_WORKSPACE_ID) --title <text> Title for rename --color <name|#hex> Color for set-color (name or #RRGGBB hex) Named colors: Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua, Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal Example: cmux workspace-action --workspace workspace:2 --action pin cmux workspace-action --action rename --title "infra" cmux workspace-action close-others cmux workspace-action --action set-color --color blue cmux workspace-action --action set-color --color "#C0392B" cmux workspace-action set-color Amber cmux workspace-action clear-color """ case "tab-action": return """ Usage: cmux tab-action --action <name> [flags] Perform horizontal tab context-menu actions from CLI/socket. Actions: rename | clear-name close-left | close-right | close-others new-terminal-right | new-browser-right reload | duplicate pin | unpin mark-unread Flags: --action <name> Action name (required if not positional) --tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) --surface <id|ref|index> Alias for --tab (backward compatibility) --workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID) --title <text> Title for rename (or pass trailing title text) --url <url> Optional URL for new-browser-right Example: cmux tab-action --tab tab:3 --action pin cmux tab-action --action close-right cmux tab-action --tab tab:2 --action rename --title "build logs" """ case "rename-tab": return """ Usage: cmux rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title> Compatibility alias for tab-action rename. Resolution order for target tab: 1) --tab 2) --surface 3) $CMUX_TAB_ID / $CMUX_SURFACE_ID 4) currently focused tab (optionally within --workspace) Flags: --workspace <id|ref> Workspace context (default: current/$CMUX_WORKSPACE_ID) --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) --surface <id|ref> Alias for --tab --title <text> Explicit title (or use trailing positional title) Examples: cmux rename-tab "build logs" cmux rename-tab --tab tab:3 "staging server" cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" """ case "new-workspace": return """ Usage: cmux new-workspace [--name <title>] [--cwd <path>] [--command <text>] Create a new workspace in the current window. Flags: --name <title> Set a custom name for the new workspace --cwd <path> Set the working directory for the new workspace --command <text> Send text+Enter to the new workspace after creation Example: cmux new-workspace cmux new-workspace --name "Build Server" cmux new-workspace --cwd ~/projects/myapp cmux new-workspace --cwd . --command "npm test" """ case "list-workspaces": return """ Usage: cmux list-workspaces List workspaces in the current window. Example: cmux list-workspaces """ case "ssh": return """ Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. Flags: --name <title> Optional workspace title --port <n> SSH port --identity <path> SSH identity file path --ssh-option <opt> Extra SSH -o option (repeatable) --no-focus Create workspace without switching to it Example: cmux ssh dev@my-host cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no """ case "remote-daemon-status": return """ Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, and the GitHub attestation verification command for a target platform. Example: cmux remote-daemon-status cmux remote-daemon-status --os linux --arch arm64 """ case "new-split": return """ Usage: cmux new-split <left|right|up|down> [flags] Split the current pane in the given direction. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface to split from (default: $CMUX_SURFACE_ID) --panel <id|ref> Alias for --surface Example: cmux new-split right cmux new-split down --workspace workspace:1 """ case "list-panes": return """ Usage: cmux list-panes [--workspace <id|ref>] List panes in a workspace. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux list-panes cmux list-panes --workspace workspace:2 """ case "list-pane-surfaces": return """ Usage: cmux list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] List surfaces in a pane. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --pane <id|ref> Restrict to a specific pane (default: focused pane) Example: cmux list-pane-surfaces cmux list-pane-surfaces --workspace workspace:2 --pane pane:1 """ case "tree": return """ Usage: cmux tree [flags] Print the hierarchy of windows, workspaces, panes, and surfaces. Flags: --all Include all windows (default: current window only) --workspace <id|ref|index> Show only one workspace --json Structured JSON output Output: Text mode prints a box-drawing tree with markers: - ◀ active (true focused window/workspace/pane/surface path) - ◀ here (caller surface where `cmux tree` was invoked) - workspace [selected] - pane [focused] - surface [selected] Browser surfaces also include their current URL. Example: cmux tree cmux tree --all cmux tree --workspace workspace:2 cmux --json tree --all """ case "focus-pane": return """ Usage: cmux focus-pane [--pane <id|ref> | <id|ref>] [flags] Focus the specified pane. Flags: --pane <id|ref> Pane to focus (required unless passed positionally) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux focus-pane --pane pane:2 cmux focus-pane pane:1 cmux focus-pane --pane pane:1 --workspace workspace:2 """ case "new-pane": return """ Usage: cmux new-pane [flags] Create a new pane in the workspace. Flags: --type <terminal|browser> Pane type (default: terminal) --direction <left|right|up|down> Split direction (default: right) --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --url <url> URL for browser panes Example: cmux new-pane cmux new-pane --type browser --direction down --url https://example.com """ case "new-surface": return """ Usage: cmux new-surface [flags] Create a new surface (tab) in a pane. Flags: --type <terminal|browser> Surface type (default: terminal) --pane <id|ref> Target pane --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --url <url> URL for browser surfaces Example: cmux new-surface cmux new-surface --type browser --pane pane:1 --url https://example.com """ case "close-surface": return """ Usage: cmux close-surface [flags] Close a surface. Defaults to the focused surface if none specified. Flags: --surface <id|ref> Surface to close (default: $CMUX_SURFACE_ID) --panel <id|ref> Alias for --surface --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux close-surface cmux close-surface --surface surface:3 """ case "drag-surface-to-split": return """ Usage: cmux drag-surface-to-split --surface <id|ref> <left|right|up|down> Drag a surface into a new split in the given direction. Flags: --surface <id|ref> Surface to drag (required) --panel <id|ref> Alias for --surface Example: cmux drag-surface-to-split --surface surface:1 right cmux drag-surface-to-split --panel surface:2 down """ case "refresh-surfaces": return """ Usage: cmux refresh-surfaces Refresh surface snapshots for the focused workspace. """ case "surface-health": return """ Usage: cmux surface-health [--workspace <id|ref>] List health details for surfaces in a workspace. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux surface-health cmux surface-health --workspace workspace:2 """ case "debug-terminals": return """ Usage: cmux debug-terminals Print live Ghostty terminal runtime metadata across all windows and workspaces. Intended for debugging stray or detached terminal views. """ case "trigger-flash": return """ Usage: cmux trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] Trigger the unread flash indicator for a surface. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) --panel <id|ref> Alias for --surface Example: cmux trigger-flash cmux trigger-flash --workspace workspace:2 --surface surface:3 """ case "list-panels": return """ Usage: cmux list-panels [--workspace <id|ref>] List surfaces (panels) in a workspace. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux list-panels cmux list-panels --workspace workspace:2 """ case "focus-panel": return """ Usage: cmux focus-panel --panel <id|ref> [--workspace <id|ref>] Focus a specific panel (surface). Flags: --panel <id|ref> Panel/surface to focus (required) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) Example: cmux focus-panel --panel surface:2 cmux focus-panel --panel surface:5 --workspace workspace:2 """ case "close-workspace": return """ Usage: cmux close-workspace --workspace <id|ref|index> Close the specified workspace. Flags: --workspace <id|ref|index> Workspace to close (required) Example: cmux close-workspace --workspace workspace:2 """ case "select-workspace": return """ Usage: cmux select-workspace --workspace <id|ref|index> Select (switch to) the specified workspace. Flags: --workspace <id|ref|index> Workspace to select (required) Example: cmux select-workspace --workspace workspace:2 cmux select-workspace --workspace 0 """ case "rename-workspace", "rename-window": return """ Usage: cmux rename-workspace [--workspace <id|ref|index>] [--] <title> Rename a workspace. Defaults to the current workspace. tmux-compatible alias: rename-window Flags: --workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID) Example: cmux rename-workspace "backend logs" cmux rename-window --workspace workspace:2 "agent run" """ case "current-workspace": return """ Usage: cmux current-workspace Print the currently selected workspace ID. """ case "capture-pane": return """ Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] tmux-compatible alias for reading terminal text from a pane. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) --scrollback Include scrollback --lines <n> Return only the last N lines (implies --scrollback) Example: cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 """ case "resize-pane": return """ Usage: cmux resize-pane [--pane <id|ref>] [--workspace <id|ref>] [-L|-R|-U|-D] [--amount <n>] tmux-compatible pane resize command. Flags: --pane <id|ref> Pane to resize (default: focused pane) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) -L|-R|-U|-D Direction (default: -R) --amount <n> Resize amount (default: 1) """ case "pipe-pane": return """ Usage: cmux pipe-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <shell-command> | <shell-command>] Capture pane text and pipe it to a shell command via stdin. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface context (default: focused surface) --command <command> Shell command to run (or pass as trailing text) """ case "wait-for": return """ Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>] Wait for or signal a named synchronization token. Flags: -S, --signal Signal the token instead of waiting --timeout <seconds> Wait timeout (default: 30) """ case "swap-pane": return """ Usage: cmux swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] Swap two panes. Flags: --pane <id|ref> Source pane (required) --target-pane <id|ref> Target pane (required) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) """ case "break-pane": return """ Usage: cmux break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] Move a pane/surface out into its own pane context. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --pane <id|ref> Source pane --surface <id|ref> Source surface --no-focus Do not focus the result """ case "join-pane": return """ Usage: cmux join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] Join a pane/surface into another pane. Flags: --target-pane <id|ref> Target pane (required) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --pane <id|ref> Source pane --surface <id|ref> Source surface --no-focus Do not focus the result """ case "next-window", "previous-window", "last-window": return """ Usage: cmux \(command) Switch workspace selection (next/previous/last) in the current window. """ case "last-pane": return """ Usage: cmux last-pane [--workspace <id|ref>] Focus the previously focused pane in a workspace. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) """ case "find-window": return """ Usage: cmux find-window [--content] [--select] [query] Find workspaces by title (and optionally terminal content). Flags: --content Search terminal content in addition to workspace titles --select Select the first match """ case "clear-history": return """ Usage: cmux clear-history [--workspace <id|ref>] [--surface <id|ref>] Clear terminal scrollback history. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface context (default: focused surface) """ case "set-hook": return """ Usage: cmux set-hook [--list] [--unset <event>] | <event> <command> Manage tmux-compat hook definitions. Flags: --list List configured hooks --unset <event> Remove a hook by event name """ case "popup": return """ Usage: cmux popup tmux compatibility placeholder. This command is currently not supported. """ case "bind-key", "unbind-key", "copy-mode": return """ Usage: cmux \(command) tmux compatibility placeholder. This command is currently not supported. """ case "set-buffer": return """ Usage: cmux set-buffer [--name <name>] [--] <text> Save text into a named tmux-compat buffer. Flags: --name <name> Buffer name (default: default) """ case "paste-buffer": return """ Usage: cmux paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] Paste a named tmux-compat buffer into a surface. Flags: --name <name> Buffer name (default: default) --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface context (default: focused surface) """ case "list-buffers": return """ Usage: cmux list-buffers List tmux-compat buffers. """ case "respawn-pane": return """ Usage: cmux respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd> | <cmd>] Send a command (or default shell restart command) to a surface. Flags: --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Surface context (default: focused surface) --command <cmd> Command text (or pass trailing command text) """ case "display-message": return """ Usage: cmux display-message [-p|--print] <text> Print text (or show it via notification bridge in parity mode). Flags: -p, --print Print to stdout only """ case "read-screen": return """ Usage: cmux read-screen [flags] Read terminal text from a surface as plain text. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) --scrollback Include scrollback (not just visible viewport) --lines <n> Limit to the last n lines (implies --scrollback) Example: cmux read-screen cmux read-screen --surface surface:2 --scrollback --lines 200 """ case "send": return """ Usage: cmux send [flags] [--] <text> Send text to a terminal surface. Escape sequences: \\n and \\r send Enter, \\t sends Tab. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) Example: cmux send "echo hello" cmux send --surface surface:2 "ls -la\\n" """ case "send-key": return """ Usage: cmux send-key [flags] [--] <key> Send a key event to a terminal surface. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) Example: cmux send-key enter cmux send-key --surface surface:2 ctrl+c """ case "send-panel": return """ Usage: cmux send-panel --panel <id|ref> [flags] [--] <text> Send text to a specific panel (surface). Escape sequences: \\n and \\r send Enter, \\t sends Tab. Flags: --panel <id|ref> Target panel (required) --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux send-panel --panel surface:2 "echo hello\\n" """ case "send-key-panel": return """ Usage: cmux send-key-panel --panel <id|ref> [flags] [--] <key> Send a key event to a specific panel (surface). Flags: --panel <id|ref> Target panel (required) --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux send-key-panel --panel surface:2 enter cmux send-key-panel --panel surface:2 ctrl+c """ case "notify": return """ Usage: cmux notify [flags] Send a notification to a workspace/surface. Flags: --title <text> Notification title (default: "Notification") --subtitle <text> Notification subtitle --body <text> Notification body --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) Example: cmux notify --title "Build done" --body "All tests passed" cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" """ case "list-notifications": return """ Usage: cmux list-notifications List queued notifications. """ case "clear-notifications": return """ Usage: cmux clear-notifications Clear all queued notifications. """ case "set-status": return """ Usage: cmux set-status <key> <value> [flags] Set a sidebar status entry for a workspace. Status entries appear as pills in the sidebar tab row. Use a unique key so different tools (e.g. "claude_code", "build") can manage their own entries. Flags: --icon <name> Icon name (e.g. "sparkle", "hammer") --color <#hex> Pill color (e.g. "#ff9500") --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux set-status build "compiling" --icon hammer --color "#ff9500" cmux set-status deploy "v1.2.3" --workspace workspace:2 """ case "clear-status": return """ Usage: cmux clear-status <key> [flags] Remove a sidebar status entry by key. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux clear-status build """ case "list-status": return """ Usage: cmux list-status [flags] List all sidebar status entries for a workspace. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux list-status cmux list-status --workspace workspace:2 """ case "set-progress": return """ Usage: cmux set-progress <0.0-1.0> [flags] Set a progress bar in the sidebar for a workspace. Flags: --label <text> Label shown next to the progress bar --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux set-progress 0.5 --label "Building..." cmux set-progress 1.0 --label "Done" """ case "clear-progress": return """ Usage: cmux clear-progress [flags] Clear the sidebar progress bar for a workspace. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux clear-progress """ case "log": return """ Usage: cmux log [flags] [--] <message> Append a log entry to the sidebar for a workspace. Flags: --level <level> Log level: info, progress, success, warning, error (default: info) --source <name> Source label (e.g. "build", "test") --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux log "Build started" cmux log --level error --source build "Compilation failed" cmux log --level success -- "All 42 tests passed" """ case "clear-log": return """ Usage: cmux clear-log [flags] Clear all sidebar log entries for a workspace. Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux clear-log """ case "list-log": return """ Usage: cmux list-log [flags] List sidebar log entries for a workspace. Flags: --limit <n> Show only the last N entries --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux list-log cmux list-log --limit 5 """ case "sidebar-state": return """ Usage: cmux sidebar-state [flags] Dump all sidebar metadata for a workspace (cwd, git branch, ports, status entries, progress, log entries). Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) Example: cmux sidebar-state cmux sidebar-state --workspace workspace:2 """ case "set-app-focus": return """ Usage: cmux set-app-focus <active|inactive|clear> Override app focus state for notification routing tests. Example: cmux set-app-focus inactive cmux set-app-focus clear """ case "simulate-app-active": return """ Usage: cmux simulate-app-active Trigger the app-active handler used by notification focus tests. """ case "claude-hook": return """ Usage: cmux claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags] Hook for Claude Code integration. Reads JSON from stdin. Subcommands: session-start Signal that a Claude session has started active Alias for session-start stop Signal that a Claude session has stopped idle Alias for stop notification Forward a Claude notification notify Alias for notification prompt-submit Clear notification and set Running on user prompt Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) Example: echo '{"session_id":"abc"}' | cmux claude-hook session-start echo '{}' | cmux claude-hook stop """ case "codex": return """ Usage: cmux codex <install-hooks|uninstall-hooks> Manage Codex CLI hooks integration. Subcommands: install-hooks Install cmux hooks into ~/.codex/hooks.json uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json """ case "codex-hook": return """ Usage: cmux codex-hook <session-start|prompt-submit|stop> [flags] Hook for Codex CLI integration. Reads JSON from stdin. Gracefully no-ops when not running inside cmux. Subcommands: session-start Register a Codex session prompt-submit Set Running status on user prompt stop Send completion notification, set Idle Flags: --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) """ case "browser": return """ Usage: cmux browser [--surface <id|ref|index> | <surface>] <subcommand> [args] Browser automation commands. Most subcommands require a surface handle. A surface can be passed as `--surface <handle>` or as the first positional token. `open`/`open-split`/`new`/`identify` can run without an explicit surface. Subcommands: open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>] open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set goto|navigate <url> [--snapshot-after] back|forward|reload [--snapshot-after] url|get-url focus-webview | is-webview-focused snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] eval [--script <js> | <js>] wait [--selector <css>] [--text <text>] [--url-contains <text>|--url <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>|--timeout <seconds>] click|dblclick|hover|focus|check|uncheck|scroll-into-view [--selector <css> | <css>] [--snapshot-after] type|fill [--selector <css> | <css>] [--text <text> | <text>] [--snapshot-after] press|key|keydown|keyup [--key <key> | <key>] [--snapshot-after] select [--selector <css> | <css>] [--value <value> | <value>] [--snapshot-after] scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] screenshot [--out <path>] get <url|title|text|html|value|attr|count|box|styles> [...] text|html|value|count|box|styles|attr: [--selector <css> | <css>] attr: [--attr <name> | <name>] styles: [--property <name>] is <visible|enabled|checked> [--selector <css> | <css>] find <role|text|label|placeholder|alt|title|testid|first|last|nth> [...] role: [--name <text>] [--exact] <role> text|label|placeholder|alt|title|testid: [--exact] <text> first|last: [--selector <css> | <css>] nth: [--index <n> | <n>] [--selector <css> | <css>] frame <main|selector> [--selector <css>] dialog <accept|dismiss> [text] download [wait] [--path <path>] [--timeout-ms <ms>|--timeout <seconds>] cookies <get|set|clear> [--name <name>] [--value <value>] [--url <url>] [--domain <domain>] [--path <path>] [--expires <unix>] [--secure] [--all] storage <local|session> <get|set|clear> [...] tab <new|list|switch|close|<index>> [...] console <list|clear> errors <list|clear> highlight [--selector <css> | <css>] state <save|load> <path> addinitscript|addscript [--script <js> | <js>] addstyle [--css <css> | <css>] viewport <width> <height> geolocation|geo <latitude> <longitude> offline <true|false> trace <start|stop> [path] network <route|unroute|requests> ... route <pattern> [--abort] [--body <text>] unroute <pattern> screencast <start|stop> input <mouse|keyboard|touch> [args...] input_mouse | input_keyboard | input_touch identify [--surface <id|ref|index>] Example: cmux browser open https://example.com cmux browser surface:1 navigate https://google.com cmux browser --surface surface:1 snapshot --interactive """ // Legacy browser aliases — point users to `cmux browser --help` case "open-browser": return "Legacy alias for 'cmux browser open'. Run 'cmux browser --help' for details." case "navigate": return "Legacy alias for 'cmux browser navigate'. Run 'cmux browser --help' for details." case "browser-back": return "Legacy alias for 'cmux browser back'. Run 'cmux browser --help' for details." case "browser-forward": return "Legacy alias for 'cmux browser forward'. Run 'cmux browser --help' for details." case "browser-reload": return "Legacy alias for 'cmux browser reload'. Run 'cmux browser --help' for details." case "get-url": return "Legacy alias for 'cmux browser get-url'. Run 'cmux browser --help' for details." case "focus-webview": return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details." case "is-webview-focused": return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details." case "markdown": return """ Usage: cmux markdown open <path> [options] cmux markdown <path> (shorthand for 'open') Open a markdown file in a formatted viewer panel with live file watching. The file is rendered with rich formatting (headings, code blocks, tables, lists, blockquotes) and automatically updates when the file changes on disk. Options: --workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID) --surface <id|ref|index> Source surface to split from (default: focused surface) --window <id|ref|index> Target window --direction <left|right|up|down> Split direction (default: right) Examples: cmux markdown open plan.md cmux markdown ~/project/CHANGELOG.md cmux markdown open ./docs/design.md --workspace 0 cmux markdown open plan.md --direction down """ default: return nil } } /// Dispatch help for a subcommand. Returns true if help was printed. private func dispatchSubcommandHelp(command: String, commandArgs: [String]) -> Bool { guard commandArgs.contains("--help") || commandArgs.contains("-h") else { return false } guard let text = subcommandUsage(command) else { return false } print("cmux \(command)") print("") print(text) return true } private static let cmuxThemeOverrideBundleIdentifier = "com.cmuxterm.app" private static let cmuxThemesBlockStart = "# cmux themes start" private static let cmuxThemesBlockEnd = "# cmux themes end" private static let cmuxThemesReloadNotificationName = "com.cmuxterm.themes.reload-config" private struct ThemeSelection { let rawValue: String? let light: String? let dark: String? let sourcePath: String? } private struct ThemeReloadStatus { let requested: Bool let targetBundleIdentifier: String } private enum ThemePickerTargetMode: String { case both case light case dark } private func shouldUseInteractiveThemePicker(jsonOutput: Bool) -> Bool { guard !jsonOutput else { return false } return isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 } private func runInteractiveThemes() throws { guard let helperURL = bundledHelperURL(named: "ghostty") else { throw CLIError(message: "Bundled Ghostty theme picker helper not found") } let selection = currentThemeSelection() var environment = ProcessInfo.processInfo.environment environment["CMUX_THEME_PICKER_CONFIG"] = try cmuxThemeOverrideConfigURL().path environment["CMUX_THEME_PICKER_BUNDLE_ID"] = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier environment["CMUX_THEME_PICKER_TARGET"] = defaultThemePickerTargetMode(current: selection).rawValue environment["CMUX_THEME_PICKER_COLOR_SCHEME"] = defaultAppearancePrefersDarkThemes() ? "dark" : "light" if let light = selection.light { environment["CMUX_THEME_PICKER_INITIAL_LIGHT"] = light } if let dark = selection.dark { environment["CMUX_THEME_PICKER_INITIAL_DARK"] = dark } if let resourcesURL = bundledGhosttyResourcesURL() { environment["GHOSTTY_RESOURCES_DIR"] = resourcesURL.path } try execInteractiveHelper( executablePath: helperURL.path, arguments: ["+list-themes"], environment: environment ) } private func defaultThemePickerTargetMode(current: ThemeSelection) -> ThemePickerTargetMode { if let light = current.light, let dark = current.dark, light.caseInsensitiveCompare(dark) == .orderedSame { return .both } return defaultAppearancePrefersDarkThemes() ? .dark : .light } private func defaultAppearancePrefersDarkThemes() -> Bool { let globalDefaults = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain) let interfaceStyle = (globalDefaults?["AppleInterfaceStyle"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) return interfaceStyle?.caseInsensitiveCompare("Dark") == .orderedSame } private func bundledHelperURL(named helperName: String) -> URL? { let fileManager = FileManager.default guard let executableURL = resolvedExecutableURL() else { return nil } var candidates: [URL] = [ executableURL.deletingLastPathComponent().appendingPathComponent(helperName, isDirectory: false) ] var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.lastPathComponent == "Contents" { candidates.append( current .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("bin", isDirectory: true) .appendingPathComponent(helperName, isDirectory: false) ) } let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) let repoHelper = current .appendingPathComponent("ghostty", isDirectory: true) .appendingPathComponent("zig-out", isDirectory: true) .appendingPathComponent("bin", isDirectory: true) .appendingPathComponent(helperName, isDirectory: false) if fileManager.fileExists(atPath: projectMarker.path), fileManager.isExecutableFile(atPath: repoHelper.path) { candidates.append(repoHelper) break } guard let parent = parentSearchURL(for: current) else { break } current = parent } return candidates.first(where: { fileManager.isExecutableFile(atPath: $0.path) }) } private func execInteractiveHelper( executablePath: String, arguments: [String], environment: [String: String] ) throws -> Never { var argv = ([executablePath] + arguments).map { strdup($0) } defer { for item in argv { free(item) } } argv.append(nil) var envp = environment .map { key, value in strdup("\(key)=\(value)") } defer { for item in envp { free(item) } } envp.append(nil) execve(executablePath, &argv, &envp) let code = errno throw CLIError(message: "Failed to launch interactive theme picker: \(String(cString: strerror(code)))") } private func bundledGhosttyResourcesURL() -> URL? { let fileManager = FileManager.default guard let executableURL = resolvedExecutableURL() else { return nil } var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.lastPathComponent == "Contents" { let candidate = current .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("ghostty", isDirectory: true) if fileManager.fileExists(atPath: candidate.path) { return candidate } } let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) let repoResources = current .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("ghostty", isDirectory: true) if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoResources.path) { return repoResources } guard let parent = parentSearchURL(for: current) else { break } current = parent } return Bundle.main.resourceURL?.appendingPathComponent("ghostty", isDirectory: true) } private func runThemes(commandArgs: [String], jsonOutput: Bool) throws { if commandArgs.isEmpty { if shouldUseInteractiveThemePicker(jsonOutput: jsonOutput) { try runInteractiveThemes() return } try printThemesList(jsonOutput: jsonOutput) return } guard let subcommand = commandArgs.first else { try printThemesList(jsonOutput: jsonOutput) return } switch subcommand { case "list": if commandArgs.count > 1 { throw CLIError(message: "themes list does not take any positional arguments") } try printThemesList(jsonOutput: jsonOutput) case "set": try runThemesSet( args: Array(commandArgs.dropFirst()), jsonOutput: jsonOutput ) case "clear": if commandArgs.count > 1 { throw CLIError(message: "themes clear does not take any positional arguments") } try runThemesClear(jsonOutput: jsonOutput) default: if subcommand.hasPrefix("-") { throw CLIError(message: "Unknown themes subcommand '\(subcommand)'. Run 'cmux themes --help'.") } try runThemesSet( args: commandArgs, jsonOutput: jsonOutput ) } } private func printThemesList(jsonOutput: Bool) throws { let themes = availableThemeNames() let current = currentThemeSelection() let configPath = try cmuxThemeOverrideConfigURL().path if jsonOutput { let currentPayload: [String: Any] = [ "raw_value": current.rawValue ?? NSNull(), "light": current.light ?? NSNull(), "dark": current.dark ?? NSNull(), "source_path": current.sourcePath ?? NSNull() ] let payload: [String: Any] = [ "themes": themes.map { theme in [ "name": theme, "current_light": current.light?.caseInsensitiveCompare(theme) == .orderedSame, "current_dark": current.dark?.caseInsensitiveCompare(theme) == .orderedSame ] }, "current": currentPayload, "config_path": configPath ] print(jsonString(payload)) return } print("Current light: \(current.light ?? "inherit")") print("Current dark: \(current.dark ?? "inherit")") print("Config: \(configPath)") if let sourcePath = current.sourcePath { print("Source: \(sourcePath)") } print("") guard !themes.isEmpty else { print("No themes found.") return } for theme in themes { var badges: [String] = [] if current.light?.caseInsensitiveCompare(theme) == .orderedSame { badges.append("light") } if current.dark?.caseInsensitiveCompare(theme) == .orderedSame { badges.append("dark") } let badgeText = badges.isEmpty ? "" : " [\(badges.joined(separator: ", "))]" print("\(theme)\(badgeText)") } } private func runThemesSet(args: [String], jsonOutput: Bool) throws { let (lightOpt, rem0) = parseOption(args, name: "--light") let (darkOpt, rem1) = parseOption(rem0, name: "--dark") if let unknown = rem1.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light <theme>, --dark <theme>") } let availableThemes = availableThemeNames() let current = currentThemeSelection() let lightTheme: String? let darkTheme: String? if lightOpt == nil && darkOpt == nil { let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !joinedTheme.isEmpty else { throw CLIError(message: "themes set requires a theme name or --light/--dark flags") } let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes) lightTheme = resolved darkTheme = resolved } else { if !rem1.isEmpty { throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'") } lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark } guard let rawThemeValue = encodedThemeValue(light: lightTheme, dark: darkTheme) else { throw CLIError(message: "themes set requires at least one theme") } let configURL = try writeManagedThemeOverride(rawThemeValue: rawThemeValue) let reloadStatus = reloadThemesIfPossible() if jsonOutput { let payload: [String: Any] = [ "ok": true, "light": lightTheme ?? NSNull(), "dark": darkTheme ?? NSNull(), "raw_value": rawThemeValue, "config_path": configURL.path, "reload_requested": reloadStatus.requested, "reload_target_bundle_id": reloadStatus.targetBundleIdentifier ] print(jsonString(payload)) return } print( "OK light=\(lightTheme ?? "-") dark=\(darkTheme ?? "-") config=\(configURL.path) reload=requested" ) } private func runThemesClear(jsonOutput: Bool) throws { let configURL = try clearManagedThemeOverride() let reloadStatus = reloadThemesIfPossible() if jsonOutput { let payload: [String: Any] = [ "ok": true, "cleared": true, "config_path": configURL.path, "reload_requested": reloadStatus.requested, "reload_target_bundle_id": reloadStatus.targetBundleIdentifier ] print(jsonString(payload)) return } print("OK cleared config=\(configURL.path) reload=requested") } private func currentThemeSelection() -> ThemeSelection { var rawValue: String? var sourcePath: String? for url in themeConfigSearchURLs() { guard let contents = try? String(contentsOf: url, encoding: .utf8), let nextValue = lastThemeDirective(in: contents) else { continue } rawValue = nextValue sourcePath = url.path } return parseThemeSelection(rawValue: rawValue, sourcePath: sourcePath) } private func parseThemeSelection(rawValue: String?, sourcePath: String?) -> ThemeSelection { guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !rawValue.isEmpty else { return ThemeSelection(rawValue: nil, light: nil, dark: nil, sourcePath: sourcePath) } var fallbackTheme: String? var lightTheme: String? var darkTheme: String? for token in rawValue.split(separator: ",").map(String.init) { let entry = token.trimmingCharacters(in: .whitespacesAndNewlines) guard !entry.isEmpty else { continue } let parts = entry.split(separator: ":", maxSplits: 1).map(String.init) if parts.count != 2 { if fallbackTheme == nil { fallbackTheme = entry } continue } let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { continue } switch key { case "light": if lightTheme == nil { lightTheme = value } case "dark": if darkTheme == nil { darkTheme = value } default: if fallbackTheme == nil { fallbackTheme = value } } } let resolvedLight = lightTheme ?? fallbackTheme ?? darkTheme let resolvedDark = darkTheme ?? fallbackTheme ?? lightTheme return ThemeSelection(rawValue: rawValue, light: resolvedLight, dark: resolvedDark, sourcePath: sourcePath) } private func encodedThemeValue(light: String?, dark: String?) -> String? { let normalizedLight = light?.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedDark = dark?.trimmingCharacters(in: .whitespacesAndNewlines) switch (normalizedLight?.isEmpty == false ? normalizedLight : nil, normalizedDark?.isEmpty == false ? normalizedDark : nil) { case let (lightTheme?, darkTheme?): return "light:\(lightTheme),dark:\(darkTheme)" case let (lightTheme?, nil): return "light:\(lightTheme)" case let (nil, darkTheme?): return "dark:\(darkTheme)" case (nil, nil): return nil } } private func availableThemeNames() -> [String] { let fileManager = FileManager.default var seen: Set<String> = [] var themes: [String] = [] for directoryURL in themeDirectoryURLs() { guard let entries = try? fileManager.contentsOfDirectory( at: directoryURL, includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey], options: [.skipsHiddenFiles] ) else { continue } for entry in entries { let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) guard values?.isDirectory != true else { continue } guard values?.isRegularFile == true || values?.isRegularFile == nil else { continue } let name = entry.lastPathComponent let folded = name.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) if seen.insert(folded).inserted { themes.append(name) } } } return themes.sorted { $0.localizedStandardCompare($1) == .orderedAscending } } private func themeDirectoryURLs() -> [URL] { let fileManager = FileManager.default let processEnv = ProcessInfo.processInfo.environment var urls: [URL] = [] var seen: Set<String> = [] func appendIfExisting(_ url: URL?) { guard let url else { return } let standardized = url.standardizedFileURL guard fileManager.fileExists(atPath: standardized.path) else { return } if seen.insert(standardized.path).inserted { urls.append(standardized) } } if let resourcesDir = processEnv["GHOSTTY_RESOURCES_DIR"]?.trimmingCharacters(in: .whitespacesAndNewlines), !resourcesDir.isEmpty { appendIfExisting(URL(fileURLWithPath: resourcesDir, isDirectory: true).appendingPathComponent("themes", isDirectory: true)) } appendIfExisting( Bundle.main.resourceURL? .appendingPathComponent("ghostty", isDirectory: true) .appendingPathComponent("themes", isDirectory: true) ) if let executableURL = resolvedExecutableURL() { var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.lastPathComponent == "Resources" { appendIfExisting( current .appendingPathComponent("ghostty", isDirectory: true) .appendingPathComponent("themes", isDirectory: true) ) } if current.lastPathComponent == "Contents" { appendIfExisting( current .appendingPathComponent("Resources", isDirectory: true) .appendingPathComponent("ghostty", isDirectory: true) .appendingPathComponent("themes", isDirectory: true) ) } let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj", isDirectory: false) let repoThemes = current.appendingPathComponent("Resources/ghostty/themes", isDirectory: true) if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoThemes.path) { appendIfExisting(repoThemes) break } guard let parent = parentSearchURL(for: current) else { break } current = parent } } if let xdgDataDirs = processEnv["XDG_DATA_DIRS"] { for dataDir in xdgDataDirs.split(separator: ":").map(String.init).filter({ !$0.isEmpty }) { appendIfExisting( URL(fileURLWithPath: NSString(string: dataDir).expandingTildeInPath, isDirectory: true) .appendingPathComponent("ghostty/themes", isDirectory: true) ) } } appendIfExisting(URL(fileURLWithPath: "/Applications/Ghostty.app/Contents/Resources/ghostty/themes", isDirectory: true)) appendIfExisting(URL(fileURLWithPath: NSString(string: "~/.config/ghostty/themes").expandingTildeInPath, isDirectory: true)) appendIfExisting( URL( fileURLWithPath: NSString( string: "~/Library/Application Support/com.mitchellh.ghostty/themes" ).expandingTildeInPath, isDirectory: true ) ) return urls } private func validatedThemeName(_ rawValue: String, availableThemes: [String]) throws -> String { let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw CLIError(message: "Theme name cannot be empty") } if let matched = availableThemes.first(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) { return matched } if availableThemes.isEmpty { return trimmed } throw CLIError(message: "Unknown theme '\(trimmed)'. Run 'cmux themes' to list available themes.") } private func themeConfigSearchURLs() -> [URL] { let rawPaths = [ "~/.config/ghostty/config", "~/.config/ghostty/config.ghostty", "~/Library/Application Support/com.mitchellh.ghostty/config", "~/Library/Application Support/com.mitchellh.ghostty/config.ghostty", "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config", "~/Library/Application Support/\(Self.cmuxThemeOverrideBundleIdentifier)/config.ghostty", ] return rawPaths.map { URL(fileURLWithPath: NSString(string: $0).expandingTildeInPath, isDirectory: false) } } private func lastThemeDirective(in contents: String) -> String? { var lastValue: String? for line in contents.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) guard parts.count == 2 else { continue } guard parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { continue } let value = parts[1] .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) if !value.isEmpty { lastValue = value } } return lastValue } private func cmuxThemeOverrideConfigURL() throws -> URL { guard let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { throw CLIError(message: "Unable to resolve Application Support directory") } return appSupport .appendingPathComponent(Self.cmuxThemeOverrideBundleIdentifier, isDirectory: true) .appendingPathComponent("config.ghostty", isDirectory: false) } private func writeManagedThemeOverride(rawThemeValue: String) throws -> URL { let fileManager = FileManager.default let configURL = try cmuxThemeOverrideConfigURL() let directoryURL = configURL.deletingLastPathComponent() try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil) let existingContents = try readOptionalThemeOverrideContents(at: configURL) ?? "" let strippedContents = removingManagedThemeOverride(from: existingContents) .trimmingCharacters(in: .whitespacesAndNewlines) let block = """ \(Self.cmuxThemesBlockStart) theme = \(rawThemeValue) \(Self.cmuxThemesBlockEnd) """ let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" try nextContents.write(to: configURL, atomically: true, encoding: .utf8) return configURL } private func clearManagedThemeOverride() throws -> URL { let fileManager = FileManager.default let configURL = try cmuxThemeOverrideConfigURL() guard let existingContents = try readOptionalThemeOverrideContents(at: configURL) else { return configURL } let strippedContents = removingManagedThemeOverride(from: existingContents) .trimmingCharacters(in: .whitespacesAndNewlines) if strippedContents.isEmpty { do { try fileManager.removeItem(at: configURL) } catch { guard !isThemeOverrideFileNotFoundError(error) else { return configURL } throw error } } else { try strippedContents.appending("\n").write(to: configURL, atomically: true, encoding: .utf8) } return configURL } private func readOptionalThemeOverrideContents(at url: URL) throws -> String? { do { return try String(contentsOf: url, encoding: .utf8) } catch { guard isThemeOverrideFileNotFoundError(error) else { throw error } return nil } } private func isThemeOverrideFileNotFoundError(_ error: Error) -> Bool { let nsError = error as NSError if nsError.domain == NSCocoaErrorDomain { return nsError.code == NSFileNoSuchFileError || nsError.code == NSFileReadNoSuchFileError } if nsError.domain == NSPOSIXErrorDomain { return nsError.code == ENOENT } return false } private func removingManagedThemeOverride(from contents: String) -> String { let pattern = #"(?ms)\n?# cmux themes start\n.*?\n# cmux themes end\n?"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return contents } let fullRange = NSRange(contents.startIndex..<contents.endIndex, in: contents) return regex.stringByReplacingMatches(in: contents, options: [], range: fullRange, withTemplate: "") } private func reloadThemesIfPossible() -> ThemeReloadStatus { let bundleIdentifier = currentCmuxAppBundleIdentifier() ?? Self.cmuxThemeOverrideBundleIdentifier DistributedNotificationCenter.default().post( name: Notification.Name(Self.cmuxThemesReloadNotificationName), object: nil, userInfo: ["bundleIdentifier": bundleIdentifier] ) return ThemeReloadStatus(requested: true, targetBundleIdentifier: bundleIdentifier) } private func currentCmuxAppBundleIdentifier() -> String? { if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } if let bundleIdentifier = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } guard let executableURL = resolvedExecutableURL() else { return nil } var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.pathExtension == "app", let bundleIdentifier = Bundle(url: current)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } if current.lastPathComponent == "Contents" { let appURL = current.deletingLastPathComponent().standardizedFileURL if appURL.pathExtension == "app", let bundleIdentifier = Bundle(url: appURL)?.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), !bundleIdentifier.isEmpty { return bundleIdentifier } } guard let parent = parentSearchURL(for: current) else { break } current = parent } return nil } /// Escape and quote a string for safe embedding in a v1 socket command. /// The socket tokenizer treats `\` and `"` as special inside quoted strings, /// so both must be escaped before wrapping in double quotes. Newlines and /// carriage returns must also be escaped since the socket protocol uses /// newline as the message terminator. private func socketQuote(_ s: String) -> String { let escaped = s .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") .replacingOccurrences(of: "\n", with: "\\n") .replacingOccurrences(of: "\r", with: "\\r") return "\"\(escaped)\"" } private func parseOption(_ args: [String], name: String) -> (String?, [String]) { var remaining: [String] = [] var value: String? var skipNext = false var pastTerminator = false for (idx, arg) in args.enumerated() { if skipNext { skipNext = false continue } if arg == "--" { pastTerminator = true remaining.append(arg) continue } if !pastTerminator, arg == name, idx + 1 < args.count { value = args[idx + 1] skipNext = true continue } remaining.append(arg) } return (value, remaining) } private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) { var remaining: [String] = [] var values: [String] = [] var skipNext = false var pastTerminator = false for (idx, arg) in args.enumerated() { if skipNext { skipNext = false continue } if arg == "--" { pastTerminator = true remaining.append(arg) continue } if !pastTerminator, arg == name, idx + 1 < args.count { values.append(args[idx + 1]) skipNext = true continue } remaining.append(arg) } return (values, remaining) } private func optionValue(_ args: [String], name: String) -> String? { guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } return args[index + 1] } private func hasFlag(_ args: [String], name: String) -> Bool { args.contains(name) } private func replaceToken(_ args: [String], from: String, to: String) -> [String] { args.map { $0 == from ? to : $0 } } /// Unescape CLI escape sequences to match legacy v1 send behavior. /// \n and \r → carriage return (Enter), \t → tab. private func unescapeSendText(_ text: String) -> String { return text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") .replacingOccurrences(of: "\\t", with: "\t") } private func workspaceFromArgsOrEnv(_ args: [String], windowOverride: String? = nil) -> String? { if let explicit = optionValue(args, name: "--workspace") { return explicit } // When --window is explicitly targeted, don't fall back to env workspace from a different window if windowOverride != nil { return nil } return ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] } private func forwardSidebarMetadataCommand( _ socketCommand: String, commandArgs: [String], client: SocketClient, windowOverride: String? ) throws -> String { func insertArgumentBeforeSeparator(_ value: String, into args: inout [String]) { if let separatorIndex = args.firstIndex(of: "--") { args.insert(value, at: separatorIndex) } else { args.append(value) } } var forwardedArgs: [String] = [] var resolvedExplicitWorkspace = false var index = 0 while index < commandArgs.count { let arg = commandArgs[index] if arg == "--workspace", index + 1 < commandArgs.count { let workspaceId = try resolveWorkspaceId(commandArgs[index + 1], client: client) forwardedArgs.append("--tab=\(workspaceId)") resolvedExplicitWorkspace = true index += 2 continue } if arg.hasPrefix("--workspace=") { let rawWorkspace = String(arg.dropFirst("--workspace=".count)) let workspaceId = try resolveWorkspaceId(rawWorkspace, client: client) forwardedArgs.append("--tab=\(workspaceId)") resolvedExplicitWorkspace = true index += 1 continue } forwardedArgs.append(arg) index += 1 } if !resolvedExplicitWorkspace, let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) { let workspaceId = try resolveWorkspaceId(workspaceArg, client: client) insertArgumentBeforeSeparator("--tab=\(workspaceId)", into: &forwardedArgs) } let command = ([socketCommand] + forwardedArgs) .map(shellQuote) .joined(separator: " ") return try sendV1Command(command, client: client) } /// Pick the display handle for an item dict based on --id-format. private func textHandle(_ item: [String: Any], idFormat: CLIIDFormat) -> String { let ref = item["ref"] as? String let id = item["id"] as? String switch idFormat { case .refs: return ref ?? id ?? "?" case .uuids: return id ?? ref ?? "?" case .both: return [ref, id].compactMap({ $0 }).joined(separator: " ") } } private func v2OKSummary(_ payload: [String: Any], idFormat: CLIIDFormat, kinds: [String] = ["surface", "workspace"]) -> String { var parts = ["OK"] for kind in kinds { if let handle = formatHandle(payload, kind: kind, idFormat: idFormat) { parts.append(handle) } } return parts.joined(separator: " ") } private struct TreeCommandOptions { let includeAllWindows: Bool let workspaceHandle: String? let jsonOutput: Bool } private struct TreePath { let windowHandle: String? let workspaceHandle: String? let paneHandle: String? let surfaceHandle: String? } private func runTreeCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat ) throws { let options = try parseTreeCommandOptions(commandArgs) let payload = try buildTreePayload(options: options, client: client) if jsonOutput || options.jsonOutput { print(jsonString(formatIDs(payload, mode: idFormat))) } else { let windows = payload["windows"] as? [[String: Any]] ?? [] print(renderTreeText(windows: windows, idFormat: idFormat)) } } private func parseTreeCommandOptions(_ args: [String]) throws -> TreeCommandOptions { let (workspaceOpt, rem0) = parseOption(args, name: "--workspace") if rem0.contains("--workspace") { throw CLIError(message: "tree requires --workspace <id|ref|index>") } var includeAll = false var jsonOutput = false var remaining: [String] = [] for arg in rem0 { if arg == "--all" { includeAll = true continue } if arg == "--json" { jsonOutput = true continue } remaining.append(arg) } if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { throw CLIError(message: "tree: unknown flag '\(unknown)'. Known flags: --all --workspace <id|ref|index> --json") } if let extra = remaining.first { throw CLIError(message: "tree: unexpected argument '\(extra)'") } return TreeCommandOptions(includeAllWindows: includeAll, workspaceHandle: workspaceOpt, jsonOutput: jsonOutput) } private func buildTreePayload( options: TreeCommandOptions, client: SocketClient ) throws -> [String: Any] { var params: [String: Any] = ["all_windows": options.includeAllWindows] if let workspaceRaw = options.workspaceHandle { guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { throw CLIError(message: "Invalid workspace handle") } params["workspace_id"] = workspaceHandle } if let caller = treeCallerContextFromEnvironment() { params["caller"] = caller } do { let payload = try client.sendV2(method: "system.tree", params: params) return treePayloadWithMarkers(payload) } catch let error as CLIError where error.message.hasPrefix("method_not_found:") { // Back-compat fallback for older servers that don't support system.tree. return try buildLegacyTreePayload(options: options, params: params, client: client) } } private func buildLegacyTreePayload( options: TreeCommandOptions, params: [String: Any], client: SocketClient ) throws -> [String: Any] { var identifyParams: [String: Any] = [:] if let caller = params["caller"] as? [String: Any], !caller.isEmpty { identifyParams["caller"] = caller } let identifyPayload = try client.sendV2(method: "system.identify", params: identifyParams) let focused = identifyPayload["focused"] as? [String: Any] ?? [:] let caller = identifyPayload["caller"] as? [String: Any] ?? [:] let activePath = parseTreePath(payload: focused) let windows = try buildTreeWindowNodes(options: options, activePath: activePath, client: client) return treePayloadWithMarkers([ "active": focused.isEmpty ? NSNull() : focused, "caller": caller.isEmpty ? NSNull() : caller, "windows": windows ]) } private func buildTreeWindowNodes( options: TreeCommandOptions, activePath: TreePath, client: SocketClient ) throws -> [[String: Any]] { let windowsPayload = try client.sendV2(method: "window.list") let allWindows = windowsPayload["windows"] as? [[String: Any]] ?? [] if let workspaceRaw = options.workspaceHandle { guard let workspaceHandle = try normalizeWorkspaceHandle(workspaceRaw, client: client) else { throw CLIError(message: "Invalid workspace handle") } let workspaceListPayload = try client.sendV2(method: "workspace.list", params: ["workspace_id": workspaceHandle]) let workspaceWindowHandle = (workspaceListPayload["window_ref"] as? String) ?? (workspaceListPayload["window_id"] as? String) let window = allWindows.first(where: { treeItemMatchesHandle($0, handle: workspaceWindowHandle) }) ?? treeFallbackWindow(from: workspaceListPayload) let workspaces = workspaceListPayload["workspaces"] as? [[String: Any]] ?? [] if workspaces.isEmpty { throw CLIError(message: "Workspace not found") } let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } var node = window let isActiveWindow = treeItemMatchesHandle(node, handle: activePath.windowHandle) node["current"] = isActiveWindow node["active"] = isActiveWindow node["workspaces"] = workspaceNodes node["workspace_count"] = workspaceNodes.count return [node] } let targetWindows: [[String: Any]] if options.includeAllWindows { targetWindows = allWindows } else if let currentWindowHandle = activePath.windowHandle { let currentOnly = allWindows.filter { treeItemMatchesHandle($0, handle: currentWindowHandle) } targetWindows = currentOnly.isEmpty ? Array(allWindows.prefix(1)) : currentOnly } else { targetWindows = Array(allWindows.prefix(1)) } return try targetWindows.map { try buildTreeWindowNode( window: $0, activePath: activePath, client: client ) } } private func treeFallbackWindow(from payload: [String: Any]) -> [String: Any] { let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] let selectedWorkspace = workspaces.first(where: { ($0["selected"] as? Bool) == true }) return [ "id": payload["window_id"] ?? NSNull(), "ref": payload["window_ref"] ?? NSNull(), "index": 0, "key": false, "visible": true, "workspace_count": workspaces.count, "selected_workspace_id": selectedWorkspace?["id"] ?? NSNull(), "selected_workspace_ref": selectedWorkspace?["ref"] ?? NSNull(), ] } private func buildTreeWindowNode( window: [String: Any], activePath: TreePath, client: SocketClient ) throws -> [String: Any] { var workspaceParams: [String: Any] = [:] if let windowHandle = treeItemHandle(window) { workspaceParams["window_id"] = windowHandle } let workspacePayload = try client.sendV2(method: "workspace.list", params: workspaceParams) let workspaces = workspacePayload["workspaces"] as? [[String: Any]] ?? [] let workspaceNodes = try workspaces.map { try buildTreeWorkspaceNode(workspace: $0, activePath: activePath, client: client) } var windowNode = window let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) windowNode["current"] = isActiveWindow windowNode["active"] = isActiveWindow windowNode["workspaces"] = workspaceNodes windowNode["workspace_count"] = workspaceNodes.count return windowNode } private func buildTreeWorkspaceNode( workspace: [String: Any], activePath: TreePath, client: SocketClient ) throws -> [String: Any] { var workspaceNode = workspace guard let workspaceHandle = treeItemHandle(workspace) else { workspaceNode["panes"] = [] return workspaceNode } let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceHandle]) let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceHandle]) let panes = panePayload["panes"] as? [[String: Any]] ?? [] let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] let browserURLsByHandle = fetchTreeBrowserURLs( workspaceHandle: workspaceHandle, surfaces: surfaces, client: client ) var surfacesByPane: [String: [[String: Any]]] = [:] for surface in surfaces { var surfaceNode = surface if surfaceNode["selected"] == nil { surfaceNode["selected"] = (surfaceNode["selected_in_pane"] as? Bool) == true } surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) let surfaceType = ((surfaceNode["type"] as? String) ?? "").lowercased() if surfaceType == "browser", let url = treeBrowserURL(surface: surfaceNode, urlsByHandle: browserURLsByHandle), !url.isEmpty { surfaceNode["url"] = url } else { surfaceNode["url"] = NSNull() } guard let paneHandle = treeRelatedHandle(surfaceNode, refKey: "pane_ref", idKey: "pane_id") else { continue } surfacesByPane[paneHandle, default: []].append(surfaceNode) } for paneHandle in surfacesByPane.keys { surfacesByPane[paneHandle]?.sort { let lhs = intFromAny($0["index_in_pane"]) ?? intFromAny($0["index"]) ?? Int.max let rhs = intFromAny($1["index_in_pane"]) ?? intFromAny($1["index"]) ?? Int.max return lhs < rhs } } let paneNodes: [[String: Any]] = panes.map { pane in var paneNode = pane paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) if let paneHandle = treeItemHandle(paneNode) { paneNode["surfaces"] = surfacesByPane[paneHandle] ?? [] } else { paneNode["surfaces"] = [] } return paneNode } workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) workspaceNode["panes"] = paneNodes return workspaceNode } private func treeItemHandle(_ item: [String: Any]) -> String? { if let ref = item["ref"] as? String, !ref.isEmpty { return ref } if let id = item["id"] as? String, !id.isEmpty { return id } return nil } private func treeRelatedHandle(_ item: [String: Any], refKey: String, idKey: String) -> String? { if let ref = item[refKey] as? String, !ref.isEmpty { return ref } if let id = item[idKey] as? String, !id.isEmpty { return id } return nil } private func parseTreePath(payload: [String: Any]) -> TreePath { return TreePath( windowHandle: treeRelatedHandle(payload, refKey: "window_ref", idKey: "window_id"), workspaceHandle: treeRelatedHandle(payload, refKey: "workspace_ref", idKey: "workspace_id"), paneHandle: treeRelatedHandle(payload, refKey: "pane_ref", idKey: "pane_id"), surfaceHandle: treeRelatedHandle(payload, refKey: "surface_ref", idKey: "surface_id") ) } private func treeCallerContextFromEnvironment() -> [String: Any]? { let env = ProcessInfo.processInfo.environment let workspaceRaw = env["CMUX_WORKSPACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) let surfaceRaw = env["CMUX_SURFACE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) var caller: [String: Any] = [:] if let workspaceRaw, !workspaceRaw.isEmpty { caller["workspace_id"] = workspaceRaw } if let surfaceRaw, !surfaceRaw.isEmpty { caller["surface_id"] = surfaceRaw } return caller.isEmpty ? nil : caller } private func treePayloadWithMarkers(_ payload: [String: Any]) -> [String: Any] { let active = payload["active"] as? [String: Any] ?? [:] let caller = payload["caller"] as? [String: Any] ?? [:] let activePath = parseTreePath(payload: active) let callerPath = parseTreePath(payload: caller) var result = payload let windows = payload["windows"] as? [[String: Any]] ?? [] result["windows"] = treeApplyMarkers(windows: windows, activePath: activePath, callerPath: callerPath) if result["active"] == nil { result["active"] = active.isEmpty ? NSNull() : active } if result["caller"] == nil { result["caller"] = caller.isEmpty ? NSNull() : caller } return result } private func treeApplyMarkers( windows: [[String: Any]], activePath: TreePath, callerPath: TreePath ) -> [[String: Any]] { return windows.map { window in var windowNode = window let isActiveWindow = treeItemMatchesHandle(windowNode, handle: activePath.windowHandle) windowNode["current"] = isActiveWindow windowNode["active"] = isActiveWindow let workspaces = window["workspaces"] as? [[String: Any]] ?? [] let workspaceNodes = workspaces.map { workspace in var workspaceNode = workspace workspaceNode["active"] = treeItemMatchesHandle(workspaceNode, handle: activePath.workspaceHandle) let panes = workspace["panes"] as? [[String: Any]] ?? [] let paneNodes = panes.map { pane in var paneNode = pane paneNode["active"] = treeItemMatchesHandle(paneNode, handle: activePath.paneHandle) let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] paneNode["surfaces"] = surfaces.map { surface in var surfaceNode = surface surfaceNode["active"] = treeItemMatchesHandle(surfaceNode, handle: activePath.surfaceHandle) surfaceNode["here"] = treeItemMatchesHandle(surfaceNode, handle: callerPath.surfaceHandle) return surfaceNode } return paneNode } workspaceNode["panes"] = paneNodes return workspaceNode } windowNode["workspaces"] = workspaceNodes return windowNode } } private func fetchTreeBrowserURLs( workspaceHandle: String, surfaces: [[String: Any]], client: SocketClient ) -> [String: String] { let hasBrowserSurfaces = surfaces.contains { (($0["type"] as? String) ?? "").lowercased() == "browser" } guard hasBrowserSurfaces else { return [:] } if let payload = try? client.sendV2( method: "browser.tab.list", params: ["workspace_id": workspaceHandle] ) { let tabs = payload["tabs"] as? [[String: Any]] ?? [] var urlByHandle: [String: String] = [:] for tab in tabs { guard let url = tab["url"] as? String, !url.isEmpty else { continue } if let id = tab["id"] as? String, !id.isEmpty { urlByHandle[id] = url } if let ref = tab["ref"] as? String, !ref.isEmpty { urlByHandle[ref] = url } } return urlByHandle } // Fallback for older servers that may not support browser.tab.list. var fallbackURLs: [String: String] = [:] for surface in surfaces { guard ((surface["type"] as? String) ?? "").lowercased() == "browser" else { continue } guard let surfaceHandle = treeItemHandle(surface) else { continue } guard let payload = try? client.sendV2( method: "browser.url.get", params: ["workspace_id": workspaceHandle, "surface_id": surfaceHandle] ), let url = payload["url"] as? String, !url.isEmpty else { continue } fallbackURLs[surfaceHandle] = url if let id = surface["id"] as? String, !id.isEmpty { fallbackURLs[id] = url } if let ref = surface["ref"] as? String, !ref.isEmpty { fallbackURLs[ref] = url } } return fallbackURLs } private func treeBrowserURL(surface: [String: Any], urlsByHandle: [String: String]) -> String? { if let id = surface["id"] as? String, let url = urlsByHandle[id] { return url } if let ref = surface["ref"] as? String, let url = urlsByHandle[ref] { return url } if let handle = treeItemHandle(surface), let url = urlsByHandle[handle] { return url } return nil } private func treeItemMatchesHandle(_ item: [String: Any], handle: String?) -> Bool { guard let handle = handle?.trimmingCharacters(in: .whitespacesAndNewlines), !handle.isEmpty else { return false } return (item["id"] as? String) == handle || (item["ref"] as? String) == handle } private func renderTreeText(windows: [[String: Any]], idFormat: CLIIDFormat) -> String { guard !windows.isEmpty else { return "No windows" } var lines: [String] = [] for window in windows { lines.append(treeWindowLabel(window, idFormat: idFormat)) let workspaces = window["workspaces"] as? [[String: Any]] ?? [] for (workspaceIndex, workspace) in workspaces.enumerated() { let workspaceIsLast = workspaceIndex == workspaces.count - 1 let workspaceBranch = workspaceIsLast ? "└── " : "├── " let workspaceIndent = workspaceIsLast ? " " : "│ " lines.append("\(workspaceBranch)\(treeWorkspaceLabel(workspace, idFormat: idFormat))") let panes = workspace["panes"] as? [[String: Any]] ?? [] for (paneIndex, pane) in panes.enumerated() { let paneIsLast = paneIndex == panes.count - 1 let paneBranch = paneIsLast ? "└── " : "├── " let paneIndent = paneIsLast ? " " : "│ " lines.append("\(workspaceIndent)\(paneBranch)\(treePaneLabel(pane, idFormat: idFormat))") let surfaces = pane["surfaces"] as? [[String: Any]] ?? [] for (surfaceIndex, surface) in surfaces.enumerated() { let surfaceIsLast = surfaceIndex == surfaces.count - 1 let surfaceBranch = surfaceIsLast ? "└── " : "├── " lines.append("\(workspaceIndent)\(paneIndent)\(surfaceBranch)\(treeSurfaceLabel(surface, idFormat: idFormat))") } } } } return lines.joined(separator: "\n") } private func treeWindowLabel(_ window: [String: Any], idFormat: CLIIDFormat) -> String { var parts = ["window \(textHandle(window, idFormat: idFormat))"] if (window["current"] as? Bool) == true { parts.append("[current]") } if (window["active"] as? Bool) == true { parts.append("◀ active") } return parts.joined(separator: " ") } private func treeWorkspaceLabel(_ workspace: [String: Any], idFormat: CLIIDFormat) -> String { var parts = ["workspace \(textHandle(workspace, idFormat: idFormat))"] let title = (workspace["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") } if (workspace["selected"] as? Bool) == true { parts.append("[selected]") } if (workspace["active"] as? Bool) == true { parts.append("◀ active") } return parts.joined(separator: " ") } private func treePaneLabel(_ pane: [String: Any], idFormat: CLIIDFormat) -> String { var parts = ["pane \(textHandle(pane, idFormat: idFormat))"] if (pane["focused"] as? Bool) == true { parts.append("[focused]") } if (pane["active"] as? Bool) == true { parts.append("◀ active") } return parts.joined(separator: " ") } private func treeSurfaceLabel(_ surface: [String: Any], idFormat: CLIIDFormat) -> String { let rawType = ((surface["type"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let surfaceType = rawType.isEmpty ? "unknown" : rawType var parts = ["surface \(textHandle(surface, idFormat: idFormat))", "[\(surfaceType)]"] let title = (surface["title"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { parts.append("\"\(title)\"") } if (surface["selected"] as? Bool) == true { parts.append("[selected]") } if (surface["active"] as? Bool) == true { parts.append("◀ active") } if (surface["here"] as? Bool) == true { parts.append("◀ here") } if let tty = surface["tty"] as? String, !tty.isEmpty { parts.append("tty=\(tty)") } if surfaceType.lowercased() == "browser", let url = surface["url"] as? String, !url.isEmpty { parts.append(url) } return parts.joined(separator: " ") } private func isUUID(_ value: String) -> Bool { return UUID(uuidString: value) != nil } private func jsonString(_ object: Any) -> String { var options: JSONSerialization.WritingOptions = [.prettyPrinted] options.insert(.withoutEscapingSlashes) guard JSONSerialization.isValidJSONObject(object), let data = try? JSONSerialization.data(withJSONObject: object, options: options), let output = String(data: data, encoding: .utf8) else { return "{}" } return output } private struct TmuxParsedArguments { var flags: Set<String> = [] var options: [String: [String]] = [:] var positional: [String] = [] func hasFlag(_ flag: String) -> Bool { flags.contains(flag) } func value(_ flag: String) -> String? { options[flag]?.last } } private func parseTmuxArguments( _ args: [String], valueFlags: Set<String>, boolFlags: Set<String> ) throws -> TmuxParsedArguments { var parsed = TmuxParsedArguments() var index = 0 var pastTerminator = false while index < args.count { let arg = args[index] if pastTerminator { parsed.positional.append(arg) index += 1 continue } if arg == "--" { pastTerminator = true index += 1 continue } if !arg.hasPrefix("-") || arg == "-" { parsed.positional.append(arg) index += 1 continue } if arg.hasPrefix("--") { parsed.positional.append(arg) index += 1 continue } let cluster = Array(arg.dropFirst()) var cursor = 0 var recognizedArgument = false while cursor < cluster.count { let flag = "-" + String(cluster[cursor]) if boolFlags.contains(flag) { parsed.flags.insert(flag) cursor += 1 recognizedArgument = true continue } if valueFlags.contains(flag) { let remainder = String(cluster.dropFirst(cursor + 1)) let value: String if !remainder.isEmpty { value = remainder } else { guard index + 1 < args.count else { throw CLIError(message: "\(flag) requires a value") } index += 1 value = args[index] } parsed.options[flag, default: []].append(value) recognizedArgument = true cursor = cluster.count continue } recognizedArgument = false break } if !recognizedArgument { parsed.positional.append(arg) } index += 1 } return parsed } private func splitTmuxCommand(_ args: [String]) throws -> (command: String, args: [String]) { var index = 0 let globalValueFlags: Set<String> = ["-L", "-S", "-f"] let globalBoolFlags: Set<String> = ["-V", "-v"] while index < args.count { let arg = args[index] if !arg.hasPrefix("-") || arg == "-" { return (arg.lowercased(), Array(args.dropFirst(index + 1))) } if arg == "--" { break } // Handle -V (version) as a pseudo-command if globalBoolFlags.contains(arg) { return (arg, []) } if let flag = globalValueFlags.first(where: { arg == $0 || arg.hasPrefix($0) }) { if arg == flag { index += 1 } } index += 1 } throw CLIError(message: "tmux shim requires a command") } private func normalizedTmuxTarget(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private func tmuxWindowSelector(from raw: String?) -> String? { guard let trimmed = normalizedTmuxTarget(raw) else { return nil } if trimmed.hasPrefix("%") || trimmed.hasPrefix("pane:") { return nil } if let dot = trimmed.lastIndex(of: ".") { return String(trimmed[..<dot]) } return trimmed } private func tmuxPaneSelector(from raw: String?) -> String? { guard let trimmed = normalizedTmuxTarget(raw) else { return nil } if trimmed.hasPrefix("%") { return String(trimmed.dropFirst()) } if trimmed.hasPrefix("pane:") { return trimmed } if let dot = trimmed.lastIndex(of: ".") { return String(trimmed[trimmed.index(after: dot)...]) } return nil } private func tmuxWorkspaceItems(client: SocketClient) throws -> [[String: Any]] { let payload = try client.sendV2(method: "workspace.list") return payload["workspaces"] as? [[String: Any]] ?? [] } private func tmuxCallerWorkspaceHandle() -> String? { normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]) } private func tmuxCallerPaneHandle() -> String? { guard let pane = normalizedTmuxTarget(ProcessInfo.processInfo.environment["TMUX_PANE"]) ?? normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_PANE_ID"]) else { return nil } return pane.hasPrefix("%") ? String(pane.dropFirst()) : pane } private func tmuxCallerSurfaceHandle() -> String? { normalizedTmuxTarget(ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"]) } private func tmuxResolvedCallerWorkspaceId(client: SocketClient) -> String? { guard let callerWorkspace = tmuxCallerWorkspaceHandle() else { return nil } return try? resolveWorkspaceId(callerWorkspace, client: client) } private func tmuxCanonicalPaneId( _ handle: String, workspaceId: String, client: SocketClient ) throws -> String { if isUUID(handle) { return handle } let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) let panes = payload["panes"] as? [[String: Any]] ?? [] for pane in panes { if (pane["ref"] as? String) == handle || (pane["id"] as? String) == handle { if let id = pane["id"] as? String { return id } } } if let index = Int(handle) { for pane in panes where intFromAny(pane["index"]) == index { if let id = pane["id"] as? String { return id } } } throw CLIError(message: "Pane target not found") } private func tmuxCanonicalSurfaceId( _ handle: String, workspaceId: String, client: SocketClient ) throws -> String { let payload = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] for surface in surfaces { if (surface["ref"] as? String) == handle || (surface["id"] as? String) == handle { if let id = surface["id"] as? String { return id } } } if let index = Int(handle) { for surface in surfaces where intFromAny(surface["index"]) == index { if let id = surface["id"] as? String { return id } } } throw CLIError(message: "Surface target not found") } private func tmuxWorkspaceIdForPaneHandle(_ handle: String, client: SocketClient) throws -> String? { guard isUUID(handle) || isHandleRef(handle) else { return nil } let workspaces = try tmuxWorkspaceItems(client: client) for workspace in workspaces { guard let workspaceId = workspace["id"] as? String else { continue } let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) let panes = payload["panes"] as? [[String: Any]] ?? [] if panes.contains(where: { ($0["id"] as? String) == handle || ($0["ref"] as? String) == handle }) { return workspaceId } } return nil } private func tmuxFocusedPaneId(workspaceId: String, client: SocketClient) throws -> String { let payload = try client.sendV2(method: "surface.current", params: ["workspace_id": workspaceId]) if let paneId = payload["pane_id"] as? String { return paneId } if let paneRef = payload["pane_ref"] as? String { return try tmuxCanonicalPaneId(paneRef, workspaceId: workspaceId, client: client) } throw CLIError(message: "Pane target not found") } private func tmuxResolveWorkspaceTarget(_ raw: String?, client: SocketClient) throws -> String { guard var token = normalizedTmuxTarget(raw) else { if let callerWorkspace = tmuxCallerWorkspaceHandle() { return try resolveWorkspaceId(callerWorkspace, client: client) } return try resolveWorkspaceId(nil, client: client) } if token == "!" || token == "^" || token == "-" { let payload = try client.sendV2(method: "workspace.last") if let workspaceId = payload["workspace_id"] as? String { return workspaceId } throw CLIError(message: "Previous workspace not found") } if let dot = token.lastIndex(of: ".") { token = String(token[..<dot]) } if let colon = token.lastIndex(of: ":") { let suffix = token[token.index(after: colon)...] token = suffix.isEmpty ? String(token[..<colon]) : String(suffix) } if token.hasPrefix("@") { token = String(token.dropFirst()) } if let resolvedHandle = try? normalizeWorkspaceHandle(token, client: client, allowCurrent: true) { return try resolveWorkspaceId(resolvedHandle, client: client) } let needle = token.trimmingCharacters(in: .whitespacesAndNewlines) let items = try tmuxWorkspaceItems(client: client) if let match = items.first(where: { (($0["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) == needle }), let id = match["id"] as? String { return id } throw CLIError(message: "Workspace target not found: \(token)") } private func tmuxResolvePaneTarget(_ raw: String?, client: SocketClient) throws -> (workspaceId: String, paneId: String) { let paneSelector = tmuxPaneSelector(from: raw) let workspaceSelector = tmuxWindowSelector(from: raw) let workspaceId: String = { if let workspaceSelector { return (try? tmuxResolveWorkspaceTarget(workspaceSelector, client: client)) ?? "" } if let paneSelector, let workspaceId = try? tmuxWorkspaceIdForPaneHandle(paneSelector, client: client) { return workspaceId } return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? "" }() guard !workspaceId.isEmpty else { throw CLIError(message: "Workspace target not found") } let paneId: String if let paneSelector { paneId = try tmuxCanonicalPaneId(paneSelector, workspaceId: workspaceId, client: client) } else if tmuxResolvedCallerWorkspaceId(client: client) == workspaceId, let callerPane = tmuxCallerPaneHandle(), let callerPaneId = try? tmuxCanonicalPaneId(callerPane, workspaceId: workspaceId, client: client) { paneId = callerPaneId } else { paneId = try tmuxFocusedPaneId(workspaceId: workspaceId, client: client) } return (workspaceId, paneId) } private func tmuxSelectedSurfaceId( workspaceId: String, paneId: String, client: SocketClient ) throws -> String { let payload = try client.sendV2( method: "pane.surfaces", params: ["workspace_id": workspaceId, "pane_id": paneId] ) let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] if let selected = surfaces.first(where: { ($0["selected"] as? Bool) == true }), let id = selected["id"] as? String { return id } if let first = surfaces.first?["id"] as? String { return first } throw CLIError(message: "Pane has no surface to target") } private func tmuxResolveSurfaceTarget( _ raw: String?, client: SocketClient ) throws -> (workspaceId: String, paneId: String?, surfaceId: String) { if tmuxPaneSelector(from: raw) != nil { let resolved = try tmuxResolvePaneTarget(raw, client: client) // When the target pane matches the caller's pane, prefer the caller's // exact surface (CMUX_SURFACE_ID) over the pane's currently selected // surface. The selected surface can change (e.g. tab switches) after // claude-teams started, but the caller surface stays fixed. let callerPane = tmuxCallerPaneHandle() let callerSurface = tmuxCallerSurfaceHandle() let canonicalCallerPane = callerPane.flatMap { try? tmuxCanonicalPaneId($0, workspaceId: resolved.workspaceId, client: client) } let paneMatch = callerPane != nil && (resolved.paneId == callerPane! || resolved.paneId == canonicalCallerPane) if paneMatch, let callerSurface, let surfaceId = try? tmuxCanonicalSurfaceId( callerSurface, workspaceId: resolved.workspaceId, client: client ) { return (resolved.workspaceId, resolved.paneId, surfaceId) } let surfaceId = try tmuxSelectedSurfaceId( workspaceId: resolved.workspaceId, paneId: resolved.paneId, client: client ) return (resolved.workspaceId, resolved.paneId, surfaceId) } let workspaceId = try tmuxResolveWorkspaceTarget(tmuxWindowSelector(from: raw), client: client) if tmuxWindowSelector(from: raw) == nil, tmuxResolvedCallerWorkspaceId(client: client) == workspaceId, let callerSurface = tmuxCallerSurfaceHandle(), let surfaceId = try? tmuxCanonicalSurfaceId( callerSurface, workspaceId: workspaceId, client: client ) { return (workspaceId, nil, surfaceId) } let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) return (workspaceId, nil, surfaceId) } private func tmuxAnchoredSplitTarget( workspaceId: String, client: SocketClient ) -> (targetSurfaceId: String, callerSurfaceId: String?, direction: String)? { var store = loadTmuxCompatStore() if let lastColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId { if let lastColumnId = try? tmuxCanonicalSurfaceId( lastColumn, workspaceId: workspaceId, client: client ) { // Once the agent column exists, keep stacking into it even if the // caller surface handle has churned from a stale surface:<n> ref. return (lastColumnId, nil, "down") } // Right-column anchors can outlive the pane they pointed at. // Drop stale state and rebuild from the caller surface instead. store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId = nil store.lastSplitSurface.removeValue(forKey: workspaceId) try? saveTmuxCompatStore(store) } let candidateAnchors = [ tmuxCallerSurfaceHandle(), store.mainVerticalLayouts[workspaceId]?.mainSurfaceId ].compactMap { $0 } for candidate in candidateAnchors { if let anchorSurfaceId = try? tmuxCanonicalSurfaceId( candidate, workspaceId: workspaceId, client: client ) { return (anchorSurfaceId, anchorSurfaceId, "right") } } let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil if removedLayout || removedSplit { try? saveTmuxCompatStore(store) } return nil } private func tmuxRenderFormat( _ format: String?, context: [String: String], fallback: String ) -> String { guard let format, !format.isEmpty else { return fallback } var rendered = format for (key, value) in context { rendered = rendered.replacingOccurrences(of: "#{\(key)}", with: value) } rendered = rendered.replacingOccurrences( of: "#\\{[^}]+\\}", with: "", options: .regularExpression ) let trimmed = rendered.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? fallback : trimmed } private func tmuxFormatContext( workspaceId: String, paneId: String? = nil, surfaceId: String? = nil, client: SocketClient ) throws -> [String: String] { let canonicalWorkspaceId = try resolveWorkspaceId(workspaceId, client: client) var context: [String: String] = [ "session_name": "cmux", "window_id": "@\(canonicalWorkspaceId)", "window_uuid": canonicalWorkspaceId ] let workspaceItems = try tmuxWorkspaceItems(client: client) if let workspace = workspaceItems.first(where: { ($0["id"] as? String) == canonicalWorkspaceId || ($0["ref"] as? String) == workspaceId }) { if let index = intFromAny(workspace["index"]) { context["window_index"] = String(index) } let title = ((workspace["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty { context["window_name"] = title } } let currentPayload = try client.sendV2(method: "surface.current", params: ["workspace_id": canonicalWorkspaceId]) let resolvedPaneId: String? = try { if let paneId { return try tmuxCanonicalPaneId(paneId, workspaceId: canonicalWorkspaceId, client: client) } if let currentPaneId = currentPayload["pane_id"] as? String { return currentPaneId } if let currentPaneRef = currentPayload["pane_ref"] as? String { return try tmuxCanonicalPaneId(currentPaneRef, workspaceId: canonicalWorkspaceId, client: client) } return nil }() let resolvedSurfaceId: String? = try { if let surfaceId { return try tmuxCanonicalSurfaceId(surfaceId, workspaceId: canonicalWorkspaceId, client: client) } if let resolvedPaneId { return try tmuxSelectedSurfaceId( workspaceId: canonicalWorkspaceId, paneId: resolvedPaneId, client: client ) } return currentPayload["surface_id"] as? String }() if let resolvedPaneId { context["pane_id"] = "%\(resolvedPaneId)" context["pane_uuid"] = resolvedPaneId let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": canonicalWorkspaceId]) let panes = panePayload["panes"] as? [[String: Any]] ?? [] if let pane = panes.first(where: { ($0["id"] as? String) == resolvedPaneId }), let index = intFromAny(pane["index"]) { context["pane_index"] = String(index) } } if let resolvedSurfaceId { context["surface_id"] = resolvedSurfaceId let surfacePayload = try client.sendV2(method: "surface.list", params: ["workspace_id": canonicalWorkspaceId]) let surfaces = surfacePayload["surfaces"] as? [[String: Any]] ?? [] if let surface = surfaces.first(where: { ($0["id"] as? String) == resolvedSurfaceId }) { let title = ((surface["title"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if !title.isEmpty { context["pane_title"] = title context["window_name"] = context["window_name"] ?? title } } } return context } /// Enrich a tmux format context dictionary with pane geometry data from the /// enriched pane.list response. Computes character-cell positions from pixel /// frames and cell dimensions so tmux format variables like #{pane_width}, /// #{pane_height}, #{pane_left}, #{pane_top}, #{window_width}, #{window_height} /// render correctly. private func tmuxEnrichContextWithGeometry( _ context: inout [String: String], pane: [String: Any], containerFrame: [String: Any]? ) { let isFocused = (pane["focused"] as? Bool) == true context["pane_active"] = isFocused ? "1" : "0" guard let columns = pane["columns"] as? Int, let rows = pane["rows"] as? Int else { return } context["pane_width"] = String(columns) context["pane_height"] = String(rows) let cellW = pane["cell_width_px"] as? Int ?? 0 let cellH = pane["cell_height_px"] as? Int ?? 0 guard cellW > 0, cellH > 0 else { return } if let frame = pane["pixel_frame"] as? [String: Any] { let px = frame["x"] as? Double ?? 0 let py = frame["y"] as? Double ?? 0 context["pane_left"] = String(Int(px) / cellW) context["pane_top"] = String(Int(py) / cellH) } if let cf = containerFrame { let cw = cf["width"] as? Double ?? 0 let ch = cf["height"] as? Double ?? 0 context["window_width"] = String(max(Int(cw) / cellW, 1)) context["window_height"] = String(max(Int(ch) / cellH, 1)) } } private func tmuxShellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" } private func tmuxShellCommandText(commandTokens: [String], cwd: String?) -> String? { let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) let commandText = commandTokens.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard (trimmedCwd?.isEmpty == false) || !commandText.isEmpty else { return nil } var pieces: [String] = [] if let trimmedCwd, !trimmedCwd.isEmpty { pieces.append("cd -- \(tmuxShellQuote(resolvePath(trimmedCwd)))") } if !commandText.isEmpty { pieces.append(commandText) } return pieces.joined(separator: " && ") + "\r" } private func tmuxSpecialKeyText(_ token: String) -> String? { switch token.lowercased() { case "enter", "c-m", "kpenter": return "\r" case "tab", "c-i": return "\t" case "space": return " " case "bspace", "backspace": return "\u{7f}" case "escape", "esc", "c-[": return "\u{1b}" case "c-c": return "\u{03}" case "c-d": return "\u{04}" case "c-z": return "\u{1a}" case "c-l": return "\u{0c}" default: return nil } } private func tmuxSendKeysText(from tokens: [String], literal: Bool) -> String { if literal { return tokens.joined(separator: " ") } var result = "" var pendingSpace = false for token in tokens { if let special = tmuxSpecialKeyText(token) { result += special pendingSpace = false continue } if pendingSpace { result += " " } result += token pendingSpace = true } return result } private func prependPathEntries(_ newEntries: [String], to currentPath: String?) -> String { var ordered: [String] = [] var seen: Set<String> = [] for entry in newEntries + (currentPath?.split(separator: ":").map(String.init) ?? []) where !entry.isEmpty { if seen.insert(entry).inserted { ordered.append(entry) } } return ordered.joined(separator: ":") } private struct TmuxCompatFocusedContext { let socketPath: String let workspaceId: String let windowId: String? let paneHandle: String let paneId: String? let surfaceId: String? } private func tmuxCompatResolvedSocketPath(processEnvironment: [String: String]) -> String { let envSocketPath: String? = { for key in ["CMUX_SOCKET_PATH", "CMUX_SOCKET"] { guard let raw = processEnvironment[key] else { continue } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } return nil }() let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath let source: CLISocketPathSource if let envSocketPath { source = CLISocketPathResolver.isImplicitDefaultPath(envSocketPath) ? .implicitDefault : .environment } else { source = .implicitDefault } return CLISocketPathResolver.resolve( requestedPath: requestedSocketPath, source: source, environment: processEnvironment ) } private func tmuxCompatFocusedContext( processEnvironment: [String: String], explicitPassword: String? ) -> TmuxCompatFocusedContext? { let socketPath = tmuxCompatResolvedSocketPath(processEnvironment: processEnvironment) let client = SocketClient(path: socketPath) do { try client.connect() try authenticateClientIfNeeded( client, explicitPassword: explicitPassword, socketPath: socketPath ) defer { client.close() } let payload = try client.sendV2(method: "system.identify") let focused = payload["focused"] as? [String: Any] ?? [:] let workspaceId = (focused["workspace_id"] as? String) ?? (focused["workspace_ref"] as? String) let paneId = (focused["pane_id"] as? String) ?? (focused["pane_ref"] as? String) guard let workspaceId, let paneId else { return nil } let paneHandle = paneId.trimmingCharacters(in: .whitespacesAndNewlines) guard !paneHandle.isEmpty else { return nil } let windowId = (focused["window_id"] as? String) ?? (focused["window_ref"] as? String) let surfaceId = (focused["surface_id"] as? String) ?? (focused["surface_ref"] as? String) return TmuxCompatFocusedContext( socketPath: socketPath, workspaceId: workspaceId, windowId: windowId, paneHandle: paneHandle, paneId: focused["pane_id"] as? String, surfaceId: surfaceId ) } catch { client.close() return nil } } private func isCmuxClaudeWrapper(at path: String) -> Bool { guard let data = FileManager.default.contents(atPath: path) else { return false } let prefixData = data.prefix(512) guard let prefix = String(data: prefixData, encoding: .utf8) else { return false } return prefix.contains("cmux claude wrapper - injects hooks and session tracking") } private func resolveExecutableInSearchPath( _ name: String, searchPath: String?, skip: ((String) -> Bool)? = nil ) -> String? { let entries = searchPath?.split(separator: ":").map(String.init) ?? [] for entry in entries where !entry.isEmpty { let candidate = URL(fileURLWithPath: entry, isDirectory: true) .appendingPathComponent(name, isDirectory: false) .path guard FileManager.default.isExecutableFile(atPath: candidate) else { continue } if let skip, skip(candidate) { continue } return candidate } return nil } private func resolveClaudeExecutable(searchPath: String?) -> String? { resolveExecutableInSearchPath( "claude", searchPath: searchPath, skip: { self.isCmuxClaudeWrapper(at: $0) } ) } private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool { commandArgs.contains { arg in arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=") } } private func claudeTeamsLaunchArguments(commandArgs: [String]) -> [String] { guard !claudeTeamsHasExplicitTeammateMode(commandArgs: commandArgs) else { return commandArgs } return ["--teammate-mode", "auto"] + commandArgs } private func configureTmuxCompatEnvironment( processEnvironment: [String: String], shimDirectory: URL, executablePath: String, socketPath: String, explicitPassword: String?, focusedContext: TmuxCompatFocusedContext?, tmuxPathPrefix: String, cmuxBinEnvVar: String, termOverrideEnvVar: String, extraEnvVars: [(key: String, value: String)] = [] ) { let updatedPath = prependPathEntries( [shimDirectory.path], to: processEnvironment["PATH"] ) let fakeTmuxValue: String = { if let focusedContext { let windowToken = focusedContext.windowId ?? focusedContext.workspaceId return "/tmp/\(tmuxPathPrefix)/\(focusedContext.workspaceId),\(windowToken),\(focusedContext.paneHandle)" } return processEnvironment["TMUX"] ?? "/tmp/\(tmuxPathPrefix)/default,0,0" }() let fakeTmuxPane = focusedContext.map { "%\($0.paneHandle)" } ?? processEnvironment["TMUX_PANE"] ?? "%1" let fakeTerm = processEnvironment[termOverrideEnvVar] ?? "screen-256color" setenv(cmuxBinEnvVar, executablePath, 1) setenv("PATH", updatedPath, 1) setenv("TMUX", fakeTmuxValue, 1) setenv("TMUX_PANE", fakeTmuxPane, 1) setenv("TERM", fakeTerm, 1) setenv("CMUX_SOCKET_PATH", socketPath, 1) setenv("CMUX_SOCKET", socketPath, 1) if let explicitPassword, !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { setenv("CMUX_SOCKET_PASSWORD", explicitPassword, 1) } unsetenv("TERM_PROGRAM") for envVar in extraEnvVars { setenv(envVar.key, envVar.value, 1) } if let focusedContext { setenv("CMUX_WORKSPACE_ID", focusedContext.workspaceId, 1) if let surfaceId = focusedContext.surfaceId, !surfaceId.isEmpty { setenv("CMUX_SURFACE_ID", surfaceId, 1) } } } private func configureClaudeTeamsEnvironment( processEnvironment: [String: String], shimDirectory: URL, executablePath: String, socketPath: String, explicitPassword: String?, focusedContext: TmuxCompatFocusedContext? ) { configureTmuxCompatEnvironment( processEnvironment: processEnvironment, shimDirectory: shimDirectory, executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, focusedContext: focusedContext, tmuxPathPrefix: "cmux-claude-teams", cmuxBinEnvVar: "CMUX_CLAUDE_TEAMS_CMUX_BIN", termOverrideEnvVar: "CMUX_CLAUDE_TEAMS_TERM", extraEnvVars: [ (key: "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", value: "1"), ] ) } private func createTmuxCompatShimDirectory( directoryName: String, tmuxShimScript: String ) throws -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() let root = URL(fileURLWithPath: homePath, isDirectory: true) .appendingPathComponent(".cmuxterm", isDirectory: true) .appendingPathComponent(directoryName, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true, attributes: nil) let tmuxURL = root.appendingPathComponent("tmux", isDirectory: false) try writeShimIfChanged(tmuxShimScript, to: tmuxURL) return root } private func createClaudeTeamsShimDirectory() throws -> URL { let script = """ #!/usr/bin/env bash set -euo pipefail exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@" """ return try createTmuxCompatShimDirectory( directoryName: "claude-teams-bin", tmuxShimScript: script ) } private func runClaudeTeams( commandArgs: [String], socketPath: String, explicitPassword: String? ) throws { let processEnvironment = ProcessInfo.processInfo.environment var launcherEnvironment = processEnvironment launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath launcherEnvironment["CMUX_SOCKET"] = socketPath if let explicitPassword, !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword } let shimDirectory = try createClaudeTeamsShimDirectory() let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") let focusedContext = tmuxCompatFocusedContext( processEnvironment: launcherEnvironment, explicitPassword: explicitPassword ) let bundledClaudePath = resolvedExecutableURL()? .deletingLastPathComponent() .appendingPathComponent("claude", isDirectory: false) .path let claudeExecutablePath = resolveClaudeExecutable(searchPath: launcherEnvironment["PATH"]) ?? { guard let bundledClaudePath, FileManager.default.isExecutableFile(atPath: bundledClaudePath) else { return nil } return bundledClaudePath }() configureClaudeTeamsEnvironment( processEnvironment: launcherEnvironment, shimDirectory: shimDirectory, executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, focusedContext: focusedContext ) let launchPath = claudeExecutablePath ?? "claude" let launchArguments = claudeTeamsLaunchArguments(commandArgs: commandArgs) var argv = ([launchPath] + launchArguments).map { strdup($0) } defer { for item in argv { free(item) } } argv.append(nil) if claudeExecutablePath != nil { execv(launchPath, &argv) } else { execvp("claude", &argv) } let code = errno throw CLIError(message: "Failed to launch claude: \(String(cString: strerror(code)))") } // MARK: - cmux omo (OpenCode + oh-my-openagent) private func resolveOpenCodeExecutable(searchPath: String?) -> String? { resolveExecutableInSearchPath("opencode", searchPath: searchPath) } private func createOMOShimDirectory() throws -> URL { // tmux shim: redirects tmux commands to cmux __tmux-compat // Handle -V locally (no socket needed) since __tmux-compat requires a connection. let tmuxScript = """ #!/usr/bin/env bash set -euo pipefail # Only match -V/-v as the first arg (top-level tmux flag). # -v inside subcommands (e.g. split-window -v) is a vertical split flag. case "${1:-}" in -V|-v) echo "tmux 3.4"; exit 0 ;; esac exec "${CMUX_OMO_CMUX_BIN:-cmux}" __tmux-compat "$@" """ let root = try createTmuxCompatShimDirectory( directoryName: "omo-bin", tmuxShimScript: tmuxScript ) // terminal-notifier shim: intercepts macOS notifications and routes to cmux notify let notifierURL = root.appendingPathComponent("terminal-notifier", isDirectory: false) let notifierScript = """ #!/usr/bin/env bash # Intercept terminal-notifier calls and route through cmux notify. # oh-my-openagent calls: terminal-notifier -title <t> -message <m> [-activate <id>] TITLE="" BODY="" while [[ $# -gt 0 ]]; do case "$1" in -title) TITLE="$2"; shift 2 ;; -message) BODY="$2"; shift 2 ;; *) shift ;; esac done exec "${CMUX_OMO_CMUX_BIN:-cmux}" notify --title "${TITLE:-OpenCode}" --body "${BODY:-}" """ try writeShimIfChanged(notifierScript, to: notifierURL) return root } private func writeShimIfChanged(_ script: String, to url: URL) throws { let normalized = script.trimmingCharacters(in: .whitespacesAndNewlines) let existing = try? String(contentsOf: url, encoding: .utf8) if existing?.trimmingCharacters(in: .whitespacesAndNewlines) != normalized { try script.write(to: url, atomically: false, encoding: .utf8) } try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) } private static let omoPluginName = "oh-my-opencode" private func resolveExecutableInPath(_ name: String) -> String? { let entries = ProcessInfo.processInfo.environment["PATH"]?.split(separator: ":").map(String.init) ?? [] for entry in entries where !entry.isEmpty { let candidate = URL(fileURLWithPath: entry, isDirectory: true) .appendingPathComponent(name, isDirectory: false) .path if FileManager.default.isExecutableFile(atPath: candidate) { return candidate } } return nil } private func omoUserConfigDir() -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() return URL(fileURLWithPath: homePath, isDirectory: true) .appendingPathComponent(".config", isDirectory: true) .appendingPathComponent("opencode", isDirectory: true) } private func omoShadowConfigDir() -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory() return URL(fileURLWithPath: homePath, isDirectory: true) .appendingPathComponent(".cmuxterm", isDirectory: true) .appendingPathComponent("omo-config", isDirectory: true) } private func omoFileType(at url: URL) -> FileAttributeType? { let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) return attrs?[.type] as? FileAttributeType } private func omoEnsureShadowPackageManifest(at shadowPackageURL: URL) throws { let fm = FileManager.default if omoFileType(at: shadowPackageURL) == .typeSymbolicLink { try? fm.removeItem(at: shadowPackageURL) } // Keep the shadow package isolated from stale/yanked pins in the user's // opencode package.json. bun will update this manifest with the resolved // oh-my-opencode version when installation succeeds. let packageManifest: [String: Any] = [ "dependencies": [ Self.omoPluginName: "latest" ], "name": "cmux-omo-shadow", "private": true ] let output = try JSONSerialization.data(withJSONObject: packageManifest, options: [.prettyPrinted, .sortedKeys]) let existing = try? Data(contentsOf: shadowPackageURL) if existing != output { try output.write(to: shadowPackageURL, options: .atomic) } } private func omoEnsureShadowNodeModulesSymlink( shadowNodeModules: URL, userNodeModules: URL ) throws { let fm = FileManager.default guard fm.fileExists(atPath: userNodeModules.path) else { return } if let type = omoFileType(at: shadowNodeModules) { if type == .typeSymbolicLink { let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path) if target != userNodeModules.path { try? fm.removeItem(at: shadowNodeModules) } else { return } } else { return } } if !fm.fileExists(atPath: shadowNodeModules.path) { try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules) } } private func omoRunPackageInstall( executablePath: String, arguments: [String], currentDirectoryURL: URL ) throws -> Int32 { let process = Process() process.currentDirectoryURL = currentDirectoryURL process.executableURL = URL(fileURLWithPath: executablePath) process.arguments = arguments process.standardOutput = FileHandle.standardError process.standardError = FileHandle.standardError try process.run() process.waitUntilExit() return process.terminationStatus } private func omoRequestedPort(from commandArgs: [String]) -> String? { for (index, arg) in commandArgs.enumerated() { if arg == "--port" { let nextIndex = commandArgs.index(after: index) guard nextIndex < commandArgs.endIndex else { return nil } let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } if arg.hasPrefix("--port=") { let value = String(arg.dropFirst("--port=".count)).trimmingCharacters(in: .whitespacesAndNewlines) return value.isEmpty ? nil : value } } return nil } private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? { let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0) guard socketDescriptor >= 0 else { return nil } defer { close(socketDescriptor) } var reuseAddress: Int32 = 1 _ = setsockopt( socketDescriptor, SOL_SOCKET, SO_REUSEADDR, &reuseAddress, socklen_t(MemoryLayout<Int32>.size) ) var address = sockaddr_in() address.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride) address.sin_family = sa_family_t(AF_INET) address.sin_port = port.bigEndian address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) let bindResult = withUnsafePointer(to: &address) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(socketDescriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.stride)) } } guard bindResult == 0 else { return nil } if port != 0 { return port } var boundAddress = address var boundAddressLength = socklen_t(MemoryLayout<sockaddr_in>.stride) let nameResult = withUnsafeMutablePointer(to: &boundAddress) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(socketDescriptor, $0, &boundAddressLength) } } guard nameResult == 0 else { return nil } return UInt16(bigEndian: boundAddress.sin_port) } private func omoResolvedPort( commandArgs: [String], processEnvironment: [String: String] ) -> String { if let requestedPort = omoRequestedPort(from: commandArgs) { return requestedPort } if let environmentPort = processEnvironment["OPENCODE_PORT"]? .trimmingCharacters(in: .whitespacesAndNewlines), let parsedEnvironmentPort = UInt16(environmentPort), parsedEnvironmentPort != 0, omoBindableLoopbackPort(parsedEnvironmentPort) != nil { return environmentPort } if let preferredPort = omoBindableLoopbackPort(4096) { return String(preferredPort) } if let fallbackPort = omoBindableLoopbackPort(0) { return String(fallbackPort) } return "4096" } /// Creates a shadow config directory that layers oh-my-opencode on top of the user's /// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR /// to point at the shadow directory. private func omoEnsurePlugin() throws { let userDir = omoUserConfigDir() let shadowDir = omoShadowConfigDir() let fm = FileManager.default try fm.createDirectory(at: shadowDir, withIntermediateDirectories: true, attributes: nil) // Read the user's opencode.json (if any), add the plugin, write to shadow dir let userJsonURL = userDir.appendingPathComponent("opencode.json") let shadowJsonURL = shadowDir.appendingPathComponent("opencode.json") var config: [String: Any] if let data = try? Data(contentsOf: userJsonURL) { guard let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw CLIError(message: "Failed to parse \(userJsonURL.path). Fix the JSON syntax and retry.") } config = existing } else { config = [:] } var plugins = (config["plugin"] as? [String]) ?? [] let alreadyPresent = plugins.contains { $0 == Self.omoPluginName || $0.hasPrefix("\(Self.omoPluginName)@") } if !alreadyPresent { plugins.append(Self.omoPluginName) } config["plugin"] = plugins let output = try JSONSerialization.data(withJSONObject: config, options: [.prettyPrinted, .sortedKeys]) try output.write(to: shadowJsonURL, options: .atomic) // Symlink node_modules from the user's config dir so installed packages resolve let shadowNodeModules = shadowDir.appendingPathComponent("node_modules") let userNodeModules = userDir.appendingPathComponent("node_modules") try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules) // The shadow config owns its own package metadata so yanked/stale pins in the // user's opencode package.json/bun.lock cannot poison plugin installation. let shadowPackageURL = shadowDir.appendingPathComponent("package.json") let shadowBunLockURL = shadowDir.appendingPathComponent("bun.lock") try omoEnsureShadowPackageManifest(at: shadowPackageURL) if omoFileType(at: shadowBunLockURL) == .typeSymbolicLink { try? fm.removeItem(at: shadowBunLockURL) } // Copy oh-my-opencode plugin config (jsonc) if the user has one for filename in ["oh-my-opencode.json", "oh-my-opencode.jsonc"] { let userFile = userDir.appendingPathComponent(filename) let shadowFile = shadowDir.appendingPathComponent(filename) if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) { try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile) } } // Install the package if not available via the symlinked node_modules let pluginPackageDir = shadowNodeModules.appendingPathComponent(Self.omoPluginName) if !fm.fileExists(atPath: pluginPackageDir.path) { let installDir = shadowDir if let bunPath = resolveExecutableInPath("bun") { FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) let installArguments = ["add", Self.omoPluginName] let firstAttemptStatus = try omoRunPackageInstall( executablePath: bunPath, arguments: installArguments, currentDirectoryURL: installDir ) if firstAttemptStatus != 0 { FileHandle.standardError.write("Retrying oh-my-opencode install with a clean shadow package state...\n".data(using: .utf8)!) try? fm.removeItem(at: shadowBunLockURL) try? fm.removeItem(at: shadowNodeModules) try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules) let retryStatus = try omoRunPackageInstall( executablePath: bunPath, arguments: installArguments, currentDirectoryURL: installDir ) if retryStatus != 0 { throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode") } } } else if let npmPath = resolveExecutableInPath("npm") { FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!) let status = try omoRunPackageInstall( executablePath: npmPath, arguments: ["install", Self.omoPluginName], currentDirectoryURL: installDir ) if status != 0 { throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode") } } else { throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install") } FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!) } // Ensure tmux mode is enabled in oh-my-opencode config. // Without this, the TmuxSessionManager won't spawn visual panes even though // $TMUX is set (tmux.enabled defaults to false). let omoConfigURL = shadowDir.appendingPathComponent("oh-my-opencode.json") var omoConfig: [String: Any] if let data = try? Data(contentsOf: omoConfigURL), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { omoConfig = existing } else { // Check if user has a config we symlinked, read from source let userOmoConfig = userDir.appendingPathComponent("oh-my-opencode.json") if let data = try? Data(contentsOf: userOmoConfig), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { omoConfig = existing // Remove the symlink so we can write our own copy try? fm.removeItem(at: omoConfigURL) } else { omoConfig = [:] } } var tmuxConfig = (omoConfig["tmux"] as? [String: Any]) ?? [:] var needsWrite = false if tmuxConfig["enabled"] as? Bool != true { tmuxConfig["enabled"] = true needsWrite = true } // Lower the default min widths so agent panes spawn in normal-sized windows. // oh-my-openagent defaults: main_pane_min_width=120, agent_pane_min_width=40, // requiring 161+ columns. Most terminal windows are narrower. if tmuxConfig["main_pane_min_width"] == nil { tmuxConfig["main_pane_min_width"] = 60 needsWrite = true } if tmuxConfig["agent_pane_min_width"] == nil { tmuxConfig["agent_pane_min_width"] = 30 needsWrite = true } if tmuxConfig["main_pane_size"] == nil { tmuxConfig["main_pane_size"] = 50 needsWrite = true } if needsWrite { omoConfig["tmux"] = tmuxConfig // Remove symlink if it exists (we need a real file) if let attrs = try? fm.attributesOfItem(atPath: omoConfigURL.path), attrs[.type] as? FileAttributeType == .typeSymbolicLink { try? fm.removeItem(at: omoConfigURL) } let output = try JSONSerialization.data(withJSONObject: omoConfig, options: [.prettyPrinted, .sortedKeys]) try output.write(to: omoConfigURL, options: .atomic) } // Point OpenCode at the shadow config setenv("OPENCODE_CONFIG_DIR", shadowDir.path, 1) } private func configureOMOEnvironment( processEnvironment: [String: String], shimDirectory: URL, executablePath: String, socketPath: String, explicitPassword: String?, focusedContext: TmuxCompatFocusedContext?, openCodePort: String ) { configureTmuxCompatEnvironment( processEnvironment: processEnvironment, shimDirectory: shimDirectory, executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, focusedContext: focusedContext, tmuxPathPrefix: "cmux-omo", cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN", termOverrideEnvVar: "CMUX_OMO_TERM", extraEnvVars: [(key: "OPENCODE_PORT", value: openCodePort)] ) } private func runOMO( commandArgs: [String], socketPath: String, explicitPassword: String? ) throws { let processEnvironment = ProcessInfo.processInfo.environment var launcherEnvironment = processEnvironment launcherEnvironment["CMUX_SOCKET_PATH"] = socketPath launcherEnvironment["CMUX_SOCKET"] = socketPath if let explicitPassword, !explicitPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { launcherEnvironment["CMUX_SOCKET_PASSWORD"] = explicitPassword } // Check for opencode before doing expensive plugin setup let openCodeExecutablePath = resolveOpenCodeExecutable(searchPath: launcherEnvironment["PATH"]) if openCodeExecutablePath == nil { let checkProcess = Process() checkProcess.executableURL = URL(fileURLWithPath: "/usr/bin/which") checkProcess.arguments = ["opencode"] checkProcess.standardOutput = Pipe() checkProcess.standardError = Pipe() try? checkProcess.run() checkProcess.waitUntilExit() if checkProcess.terminationStatus != 0 { throw CLIError(message: "opencode is not installed. Install it first:\n npm install -g opencode-ai\n # or\n bun install -g opencode-ai\n\nThen run: cmux omo") } } // Ensure oh-my-opencode plugin is registered and installed try omoEnsurePlugin() let shimDirectory = try createOMOShimDirectory() let executablePath = resolvedExecutableURL()?.path ?? (args.first ?? "cmux") let focusedContext = tmuxCompatFocusedContext( processEnvironment: launcherEnvironment, explicitPassword: explicitPassword ) let openCodePort = omoResolvedPort( commandArgs: commandArgs, processEnvironment: launcherEnvironment ) launcherEnvironment["OPENCODE_PORT"] = openCodePort configureOMOEnvironment( processEnvironment: launcherEnvironment, shimDirectory: shimDirectory, executablePath: executablePath, socketPath: socketPath, explicitPassword: explicitPassword, focusedContext: focusedContext, openCodePort: openCodePort ) let launchPath = openCodeExecutablePath ?? "opencode" // oh-my-openagent needs the OpenCode API server running to attach // subagent sessions to tmux panes. Prefer the historic default port // when it is available, otherwise fall back to a free loopback port. var effectiveArgs = commandArgs if omoRequestedPort(from: commandArgs) == nil { effectiveArgs.append("--port") effectiveArgs.append(openCodePort) } var argv = ([launchPath] + effectiveArgs).map { strdup($0) } defer { for item in argv { free(item) } } argv.append(nil) if openCodeExecutablePath != nil { execv(launchPath, &argv) } else { execvp("opencode", &argv) } let code = errno throw CLIError(message: "Failed to launch opencode: \(String(cString: strerror(code)))\n\nIs opencode installed? Install with:\n npm install -g opencode-ai") } private func runClaudeTeamsTmuxCompat( commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat, windowOverride: String? ) throws { let (command, rawArgs) = try splitTmuxCommand(commandArgs) switch command { case "new-session", "new": let parsed = try parseTmuxArguments( rawArgs, valueFlags: ["-c", "-F", "-n", "-s"], boolFlags: ["-A", "-d", "-P"] ) if parsed.hasFlag("-A") { throw CLIError(message: "new-session -A is not supported in cmux claude-teams mode") } var params: [String: Any] = ["focus": false] if let cwd = parsed.value("-c") { params["cwd"] = resolvePath(cwd) } let created = try client.sendV2(method: "workspace.create", params: params) guard let workspaceId = created["workspace_id"] as? String else { throw CLIError(message: "workspace.create did not return workspace_id") } if let title = parsed.value("-n") ?? parsed.value("-s"), !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { _ = try client.sendV2(method: "workspace.rename", params: [ "workspace_id": workspaceId, "title": title ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, "surface_id": surfaceId, "text": text ]) } if parsed.hasFlag("-P") { let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) } case "new-window", "neww": let parsed = try parseTmuxArguments( rawArgs, valueFlags: ["-c", "-F", "-n", "-t"], boolFlags: ["-d", "-P"] ) if parsed.value("-t") != nil { throw CLIError(message: "new-window -t is not supported in cmux claude-teams mode") } var params: [String: Any] = ["focus": false] if let cwd = parsed.value("-c") { params["cwd"] = resolvePath(cwd) } let created = try client.sendV2(method: "workspace.create", params: params) guard let workspaceId = created["workspace_id"] as? String else { throw CLIError(message: "workspace.create did not return workspace_id") } if let title = parsed.value("-n"), !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { _ = try client.sendV2(method: "workspace.rename", params: [ "workspace_id": workspaceId, "title": title ]) } if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { let surfaceId = try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": workspaceId, "surface_id": surfaceId, "text": text ]) } if parsed.hasFlag("-P") { let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: "@\(workspaceId)")) } case "split-window", "splitw": let parsed = try parseTmuxArguments( rawArgs, valueFlags: ["-c", "-F", "-l", "-t"], boolFlags: ["-P", "-b", "-d", "-h", "-v"] ) var target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) var direction: String var anchoredCallerSurfaceId: String? if parsed.hasFlag("-h") { direction = parsed.hasFlag("-b") ? "left" : "right" } else { direction = parsed.hasFlag("-b") ? "up" : "down" } // Claude's agent teams targets arbitrary panes (from list-panes), // not necessarily the leader pane from TMUX_PANE. Override the // target to anchor all teammate splits to the leader surface. // Only apply caller anchoring when the caller's workspace resolves // successfully. Falling back to target.workspaceId would pair // the caller's surface with a different workspace, creating an // invalid cross-workspace split. if let callerWorkspace = tmuxCallerWorkspaceHandle(), let wsId = try? resolveWorkspaceId(callerWorkspace, client: client), let anchoredTarget = tmuxAnchoredSplitTarget(workspaceId: wsId, client: client) { target = (wsId, nil, anchoredTarget.targetSurfaceId) direction = anchoredTarget.direction anchoredCallerSurfaceId = anchoredTarget.callerSurfaceId } // Keep the leader pane focused while agents spawn beside it. // -d explicitly means "don't focus the new pane". let focusNewPane = !parsed.hasFlag("-d") let created = try client.sendV2(method: "surface.split", params: [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, "direction": direction, "focus": focusNewPane ]) guard let surfaceId = created["surface_id"] as? String else { throw CLIError(message: "surface.split did not return surface_id") } let paneId = created["pane_id"] as? String // Track the newly created pane for main-vertical layout. do { var updatedStore = loadTmuxCompatStore() updatedStore.lastSplitSurface[target.workspaceId] = surfaceId if updatedStore.mainVerticalLayouts[target.workspaceId] != nil { updatedStore.mainVerticalLayouts[target.workspaceId]?.lastColumnSurfaceId = surfaceId } else if direction == "right", let anchoredCallerSurfaceId { // First right split created the column; seed main-vertical // state so subsequent splits stack downward. updatedStore.mainVerticalLayouts[target.workspaceId] = MainVerticalState( mainSurfaceId: anchoredCallerSurfaceId, lastColumnSurfaceId: surfaceId ) } try saveTmuxCompatStore(updatedStore) } // Equalize vertical splits so teammate panes are evenly distributed. // Use orientation: "vertical" to only equalize the agent column, // preserving the leader/column horizontal divider position. _ = try? client.sendV2(method: "workspace.equalize_splits", params: [ "workspace_id": target.workspaceId, "orientation": "vertical" ]) if let text = tmuxShellCommandText(commandTokens: parsed.positional, cwd: parsed.value("-c")) { _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": target.workspaceId, "surface_id": surfaceId, "text": text ]) } if parsed.hasFlag("-P") { let context = try tmuxFormatContext( workspaceId: target.workspaceId, paneId: paneId, surfaceId: surfaceId, client: client ) let fallback = context["pane_id"] ?? surfaceId print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) } case "select-window", "selectw": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": workspaceId]) case "select-pane", "selectp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-P", "-T", "-t"], boolFlags: []) if parsed.value("-P") != nil || parsed.value("-T") != nil { return } let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "pane.focus", params: [ "workspace_id": target.workspaceId, "pane_id": target.paneId ]) case "kill-window", "killw": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "workspace.close", params: ["workspace_id": workspaceId]) try? tmuxPruneCompatWorkspaceState(workspaceId: workspaceId) case "kill-pane", "killp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "surface.close", params: [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId ]) try? tmuxPruneCompatSurfaceState( workspaceId: target.workspaceId, surfaceId: target.surfaceId, client: client ) // Re-equalize the agent column after removing a pane _ = try? client.sendV2(method: "workspace.equalize_splits", params: [ "workspace_id": target.workspaceId, "orientation": "vertical" ]) case "send-keys", "send": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: ["-l"]) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) let text = tmuxSendKeysText(from: parsed.positional, literal: parsed.hasFlag("-l")) if !text.isEmpty { _ = try client.sendV2(method: "surface.send_text", params: [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, "text": text ]) } case "capture-pane", "capturep": let parsed = try parseTmuxArguments( rawArgs, valueFlags: ["-E", "-S", "-t"], boolFlags: ["-J", "-N", "-p"] ) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) var params: [String: Any] = [ "workspace_id": target.workspaceId, "surface_id": target.surfaceId, "scrollback": true ] if let start = parsed.value("-S"), let lines = Int(start), lines < 0 { params["lines"] = abs(lines) } let payload = try client.sendV2(method: "surface.read_text", params: params) let text = (payload["text"] as? String) ?? "" if parsed.hasFlag("-p") { print(text) } else { var store = loadTmuxCompatStore() store.buffers["default"] = text try saveTmuxCompatStore(store) } case "display-message", "display", "displayp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: ["-p"]) let target = try tmuxResolveSurfaceTarget(parsed.value("-t"), client: client) var context = try tmuxFormatContext( workspaceId: target.workspaceId, paneId: target.paneId, surfaceId: target.surfaceId, client: client ) // Enrich with geometry for format strings like #{pane_width},#{window_width} let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId]) let panesList = panePayload["panes"] as? [[String: Any]] ?? [] let containerFrame = panePayload["container_frame"] as? [String: Any] if let targetPaneId = target.paneId, let matchingPane = panesList.first(where: { ($0["id"] as? String) == targetPaneId }) { tmuxEnrichContextWithGeometry(&context, pane: matchingPane, containerFrame: containerFrame) } else if let firstPane = panesList.first(where: { ($0["focused"] as? Bool) == true }) ?? panesList.first { tmuxEnrichContextWithGeometry(&context, pane: firstPane, containerFrame: containerFrame) } let format = parsed.positional.isEmpty ? parsed.value("-F") : parsed.positional.joined(separator: " ") let rendered = tmuxRenderFormat(format, context: context, fallback: "") if parsed.hasFlag("-p") || !rendered.isEmpty { print(rendered) } case "list-windows", "lsw": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) let items = try tmuxWorkspaceItems(client: client) for item in items { guard let workspaceId = item["id"] as? String else { continue } let context = try tmuxFormatContext(workspaceId: workspaceId, client: client) let fallback = [ context["window_index"] ?? "?", context["window_name"] ?? workspaceId ].joined(separator: " ") print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) } case "list-panes", "lsp": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-F", "-t"], boolFlags: []) // Resolve target: can be a pane (%uuid) or workspace. In tmux, // list-panes -t %<pane> lists all panes in the window containing that pane. let workspaceId: String if let target = parsed.value("-t"), tmuxPaneSelector(from: target) != nil { let paneTarget = try tmuxResolvePaneTarget(target, client: client) workspaceId = paneTarget.workspaceId } else { workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) } let payload = try client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) let panes = payload["panes"] as? [[String: Any]] ?? [] let containerFrame = payload["container_frame"] as? [String: Any] for pane in panes { guard let paneId = pane["id"] as? String else { continue } var context = try tmuxFormatContext(workspaceId: workspaceId, paneId: paneId, client: client) tmuxEnrichContextWithGeometry(&context, pane: pane, containerFrame: containerFrame) let fallback = context["pane_id"] ?? paneId print(tmuxRenderFormat(parsed.value("-F"), context: context, fallback: fallback)) } case "rename-window", "renamew": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let title = parsed.positional.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { throw CLIError(message: "rename-window requires a title") } let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "workspace.rename", params: [ "workspace_id": workspaceId, "title": title ]) case "resize-pane", "resizep": let parsed = try parseTmuxArguments( rawArgs, valueFlags: ["-t", "-x", "-y"], boolFlags: ["-D", "-L", "-R", "-U"] ) let hasDirectionalFlags = parsed.hasFlag("-L") || parsed.hasFlag("-R") || parsed.hasFlag("-U") || parsed.hasFlag("-D") let target = try tmuxResolvePaneTarget(parsed.value("-t"), client: client) if !hasDirectionalFlags, let absWidth = parsed.value("-x").flatMap({ Int($0.replacingOccurrences(of: "%", with: "")) }) { // Absolute width: resize-pane -t <pane> -x <columns> // Compute pixel delta from current width to desired width. let panePayload = try client.sendV2(method: "pane.list", params: ["workspace_id": target.workspaceId]) let panes = panePayload["panes"] as? [[String: Any]] ?? [] if let matchingPane = panes.first(where: { ($0["id"] as? String) == target.paneId }), let cellW = matchingPane["cell_width_px"] as? Int, cellW > 0, let currentCols = matchingPane["columns"] as? Int { let delta = absWidth - currentCols if delta != 0 { _ = try? client.sendV2(method: "pane.resize", params: [ "workspace_id": target.workspaceId, "pane_id": target.paneId, "direction": delta > 0 ? "right" : "left", "amount": abs(delta) * cellW ]) } } } else if hasDirectionalFlags { let direction: String if parsed.hasFlag("-L") { direction = "left" } else if parsed.hasFlag("-U") { direction = "up" } else if parsed.hasFlag("-D") { direction = "down" } else { direction = "right" } let rawAmount = (parsed.value("-x") ?? parsed.value("-y") ?? "5") .replacingOccurrences(of: "%", with: "") let amount = Int(rawAmount) ?? 5 _ = try client.sendV2(method: "pane.resize", params: [ "workspace_id": target.workspaceId, "pane_id": target.paneId, "direction": direction, "amount": max(1, amount) ]) } case "wait-for": try runTmuxCompatCommand( command: "wait-for", commandArgs: rawArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowOverride ) case "last-pane": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let workspaceId = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) _ = try client.sendV2(method: "pane.last", params: ["workspace_id": workspaceId]) case "show-buffer", "showb": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) let name = parsed.value("-b") ?? "default" let store = loadTmuxCompatStore() if let buffer = store.buffers[name] { print(buffer) } case "save-buffer", "saveb": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-b"], boolFlags: []) let name = parsed.value("-b") ?? "default" let store = loadTmuxCompatStore() guard let buffer = store.buffers[name] else { throw CLIError(message: "Buffer not found: \(name)") } if let outputPath = parsed.positional.last, !outputPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { try buffer.write(toFile: resolvePath(outputPath), atomically: true, encoding: .utf8) } else { print(buffer) } case "last-window", "next-window", "previous-window", "set-hook", "set-buffer", "list-buffers": try runTmuxCompatCommand( command: command, commandArgs: rawArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowOverride ) case "has-session", "has": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) _ = try tmuxResolveWorkspaceTarget(parsed.value("-t"), client: client) case "select-layout": let parsed = try parseTmuxArguments(rawArgs, valueFlags: ["-t"], boolFlags: []) let layoutName = parsed.positional.first ?? "" // select-layout -t accepts pane targets (e.g. %1) in real tmux. // Try pane target first, then workspace target. Only fall back to // the caller's current workspace when no -t was provided; an // explicit -t that fails to resolve should error, not silently // apply to the wrong workspace. let workspaceId: String = { if let target = parsed.value("-t") { if let resolved = try? tmuxResolvePaneTarget(target, client: client) { return resolved.workspaceId } return (try? tmuxResolveWorkspaceTarget(target, client: client)) ?? "" } return (try? tmuxResolveWorkspaceTarget(nil, client: client)) ?? "" }() guard !workspaceId.isEmpty else { throw CLIError(message: "Could not resolve workspace for select-layout") } if layoutName == "main-vertical" || layoutName == "main-horizontal" { // For main-* layouts, only equalize the agent column (vertical splits), // not the top-level horizontal split between main and agents. let orientation = layoutName == "main-vertical" ? "vertical" : "horizontal" _ = try? client.sendV2(method: "workspace.equalize_splits", params: [ "workspace_id": workspaceId, "orientation": orientation ]) } else { // For tiled/even-* layouts, equalize everything _ = try? client.sendV2(method: "workspace.equalize_splits", params: ["workspace_id": workspaceId]) } if layoutName == "main-vertical" { if let callerSurface = tmuxCallerSurfaceHandle() { var store = loadTmuxCompatStore() let existingColumn = store.mainVerticalLayouts[workspaceId]?.lastColumnSurfaceId let seedColumn = existingColumn ?? store.lastSplitSurface[workspaceId] store.mainVerticalLayouts[workspaceId] = MainVerticalState( mainSurfaceId: callerSurface, lastColumnSurfaceId: seedColumn ) try saveTmuxCompatStore(store) } } else if !layoutName.isEmpty { // Non-main-vertical layout selected: clear stale state so // future splits don't incorrectly redirect to the old column. try tmuxPruneCompatWorkspaceState(workspaceId: workspaceId) } case "set-option", "set", "set-window-option", "setw", "source-file", "refresh-client", "attach-session", "detach-client": return case "-V", "-v": print("tmux 3.4") return default: throw CLIError(message: "Unsupported tmux compatibility command: \(command)") } } private struct MainVerticalState: Codable { /// The surface ID of the "main" (leader) pane on the left side. var mainSurfaceId: String /// The surface ID of the bottom-most pane in the right column. /// Subsequent teammate splits target this pane with direction "down". var lastColumnSurfaceId: String? } private struct TmuxCompatStore: Codable { var buffers: [String: String] = [:] var hooks: [String: String] = [:] /// Tracks main-vertical layout state per workspace, keyed by workspace ID. var mainVerticalLayouts: [String: MainVerticalState] = [:] /// Tracks the last surface created by split-window per workspace. /// Used to seed lastColumnSurfaceId when select-layout main-vertical /// is called after the first split. var lastSplitSurface: [String: String] = [:] /// Custom decoder so older store files missing newer keys /// (mainVerticalLayouts, lastSplitSurface) decode gracefully /// instead of throwing and resetting the entire store. init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) buffers = try container.decodeIfPresent([String: String].self, forKey: .buffers) ?? [:] hooks = try container.decodeIfPresent([String: String].self, forKey: .hooks) ?? [:] mainVerticalLayouts = try container.decodeIfPresent([String: MainVerticalState].self, forKey: .mainVerticalLayouts) ?? [:] lastSplitSurface = try container.decodeIfPresent([String: String].self, forKey: .lastSplitSurface) ?? [:] } init() {} } private func tmuxCompatStoreURL() -> URL { let homePath = ProcessInfo.processInfo.environment["HOME"] ?? NSString(string: "~").expandingTildeInPath return URL(fileURLWithPath: homePath) .appendingPathComponent(".cmuxterm") .appendingPathComponent("tmux-compat-store.json") } private func loadTmuxCompatStore() -> TmuxCompatStore { let url = tmuxCompatStoreURL() guard let data = try? Data(contentsOf: url), let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else { return TmuxCompatStore() } return decoded } private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws { let url = tmuxCompatStoreURL() let parent = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil) let data = try JSONEncoder().encode(store) try data.write(to: url, options: .atomic) } private func tmuxPruneCompatWorkspaceState(workspaceId: String) throws { var store = loadTmuxCompatStore() let removedLayout = store.mainVerticalLayouts.removeValue(forKey: workspaceId) != nil let removedSplit = store.lastSplitSurface.removeValue(forKey: workspaceId) != nil if removedLayout || removedSplit { try saveTmuxCompatStore(store) } } private func tmuxCompatPaneAnchorSurfaceId(_ pane: [String: Any]) -> String? { if let selected = pane["selected_surface_id"] as? String, !selected.isEmpty { return selected } let surfaceIds = pane["surface_ids"] as? [String] ?? [] return surfaceIds.first } private func tmuxCompatPanePixelFrame(_ pane: [String: Any]) -> (x: Double, y: Double)? { guard let frame = pane["pixel_frame"] as? [String: Any], let x = doubleFromAny(frame["x"]), let y = doubleFromAny(frame["y"]) else { return nil } return (x, y) } private func tmuxReplacementColumnSurfaceId( workspaceId: String, layout: MainVerticalState, client: SocketClient ) -> String? { guard let payload = try? client.sendV2(method: "pane.list", params: ["workspace_id": workspaceId]) else { return nil } let panes = payload["panes"] as? [[String: Any]] ?? [] guard !panes.isEmpty else { return nil } guard let mainPane = panes.first(where: { pane in let surfaceIds = pane["surface_ids"] as? [String] ?? [] if surfaceIds.contains(layout.mainSurfaceId) { return true } return (pane["selected_surface_id"] as? String) == layout.mainSurfaceId }) else { return nil } let mainPaneId = mainPane["id"] as? String let nonMainPanes = panes.filter { ($0["id"] as? String) != mainPaneId } guard !nonMainPanes.isEmpty else { return nil } let candidatePanes: [[String: Any]] if let mainFrame = tmuxCompatPanePixelFrame(mainPane) { let rightColumn = nonMainPanes.filter { pane in guard let frame = tmuxCompatPanePixelFrame(pane) else { return false } return frame.x > mainFrame.x + 0.5 } candidatePanes = rightColumn.isEmpty ? nonMainPanes : rightColumn } else { candidatePanes = nonMainPanes } let bottomMostPane = candidatePanes.max { lhs, rhs in let lhsFrame = tmuxCompatPanePixelFrame(lhs) let rhsFrame = tmuxCompatPanePixelFrame(rhs) switch (lhsFrame, rhsFrame) { case let (.some(lhsFrame), .some(rhsFrame)): if lhsFrame.y == rhsFrame.y { return lhsFrame.x < rhsFrame.x } return lhsFrame.y < rhsFrame.y case (.none, .some): return true case (.some, .none): return false case (.none, .none): return false } } return bottomMostPane.flatMap { tmuxCompatPaneAnchorSurfaceId($0) } } private func tmuxPruneCompatSurfaceState( workspaceId: String, surfaceId: String, client: SocketClient ) throws { var store = loadTmuxCompatStore() var changed = false if store.lastSplitSurface[workspaceId] == surfaceId { store.lastSplitSurface.removeValue(forKey: workspaceId) changed = true } if let layout = store.mainVerticalLayouts[workspaceId] { if layout.mainSurfaceId == surfaceId { store.mainVerticalLayouts.removeValue(forKey: workspaceId) store.lastSplitSurface.removeValue(forKey: workspaceId) changed = true } else if layout.lastColumnSurfaceId == surfaceId { var updatedLayout = layout let replacementSurfaceId = tmuxReplacementColumnSurfaceId( workspaceId: workspaceId, layout: layout, client: client ) updatedLayout.lastColumnSurfaceId = replacementSurfaceId store.mainVerticalLayouts[workspaceId] = updatedLayout if let replacementSurfaceId { store.lastSplitSurface[workspaceId] = replacementSurfaceId } else { store.lastSplitSurface.removeValue(forKey: workspaceId) } changed = true } } if changed { try saveTmuxCompatStore(store) } } private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/zsh") process.arguments = ["-lc", command] let stdinPipe = Pipe() let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardInput = stdinPipe process.standardOutput = stdoutPipe process.standardError = stderrPipe try process.run() if let data = stdinText.data(using: .utf8) { stdinPipe.fileHandleForWriting.write(data) } stdinPipe.fileHandleForWriting.closeFile() process.waitUntilExit() let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" return (process.terminationStatus, stdout, stderr) } private func tmuxWaitForSignalURL(name: String) -> URL { let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-")) let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig") } private func runTmuxCompatCommand( command: String, commandArgs: [String], client: SocketClient, jsonOutput: Bool, idFormat: CLIIDFormat, windowOverride: String? ) throws { switch command { case "capture-pane": let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") let (linesArg, rem2) = parseOption(rem1, name: "--lines") let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil) let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let sfId { params["surface_id"] = sfId } let includeScrollback = rem2.contains("--scrollback") if includeScrollback { params["scrollback"] = true } if let linesArg { guard let lineCount = Int(linesArg), lineCount > 0 else { throw CLIError(message: "--lines must be greater than 0") } params["lines"] = lineCount params["scrollback"] = true } let payload = try client.sendV2(method: "surface.read_text", params: params) if jsonOutput { print(jsonString(payload)) } else { print((payload["text"] as? String) ?? "") } case "resize-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let paneArg = optionValue(commandArgs, name: "--pane") let amountArg = optionValue(commandArgs, name: "--amount") let amount = Int(amountArg ?? "1") ?? 1 if amount <= 0 { throw CLIError(message: "--amount must be greater than 0") } let direction: String = { if commandArgs.contains("-L") { return "left" } if commandArgs.contains("-R") { return "right" } if commandArgs.contains("-U") { return "up" } if commandArgs.contains("-D") { return "down" } return "right" }() var params: [String: Any] = ["direction": direction, "amount": amount] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true) if let paneId { params["pane_id"] = paneId } let payload = try client.sendV2(method: "pane.resize", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) case "pipe-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let surfaceArg = optionValue(commandArgs, name: "--surface") let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command") let commandText: String = { if let cmdOpt { return cmdOpt } let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) return trimmed }() guard !commandText.isEmpty else { throw CLIError(message: "pipe-pane requires --command <shell-command>") } var params: [String: Any] = ["scrollback": true] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.read_text", params: params) let text = (payload["text"] as? String) ?? "" let shell = try runShellCommand(commandText, stdinText: text) if shell.status != 0 { throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)") } if jsonOutput { print(jsonString([ "ok": true, "status": shell.status, "stdout": shell.stdout, "stderr": shell.stderr ])) } else { if !shell.stdout.isEmpty { print(shell.stdout, terminator: "") } print("OK") } case "wait-for": let signal = commandArgs.contains("-S") || commandArgs.contains("--signal") let timeoutRaw = optionValue(commandArgs, name: "--timeout") let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0 let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? "" guard !name.isEmpty else { throw CLIError(message: "wait-for requires a name") } let signalURL = tmuxWaitForSignalURL(name: name) if signal { FileManager.default.createFile(atPath: signalURL.path, contents: Data()) print("OK") return } let deadline = Date().addingTimeInterval(timeout) do { try SocketClient.waitForFilesystemPath(signalURL.path, timeout: max(0, deadline.timeIntervalSinceNow)) try? FileManager.default.removeItem(at: signalURL) print("OK") return } catch { if FileManager.default.fileExists(atPath: signalURL.path) { try? FileManager.default.removeItem(at: signalURL) print("OK") return } } throw CLIError(message: "wait-for timed out waiting for '\(name)'") case "swap-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else { throw CLIError(message: "swap-pane requires --pane") } guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else { throw CLIError(message: "swap-pane requires --target-pane") } var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId) let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId) if let sourcePane { params["pane_id"] = sourcePane } if let targetPane { params["target_pane_id"] = targetPane } let payload = try client.sendV2(method: "pane.swap", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "break-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let paneArg = optionValue(commandArgs, name: "--pane") let surfaceArg = optionValue(commandArgs, name: "--surface") var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId) if let paneId { params["pane_id"] = paneId } let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let surfaceId { params["surface_id"] = surfaceId } let payload = try client.sendV2(method: "pane.break", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "join-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let sourcePaneArg = optionValue(commandArgs, name: "--pane") let surfaceArg = optionValue(commandArgs, name: "--surface") guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else { throw CLIError(message: "join-pane requires --target-pane") } var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId) if let sourcePaneId { params["pane_id"] = sourcePaneId } let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId) if let targetPaneId { params["target_pane_id"] = targetPaneId } let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId) if let surfaceId { params["surface_id"] = surfaceId } let payload = try client.sendV2(method: "pane.join", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "last-window": let payload = try client.sendV2(method: "workspace.last") printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "next-window": let payload = try client.sendV2(method: "workspace.next") printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "previous-window": let payload = try client.sendV2(method: "workspace.previous") printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"])) case "last-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let payload = try client.sendV2(method: "pane.last", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"])) case "find-window": let includeContent = commandArgs.contains("--content") let shouldSelect = commandArgs.contains("--select") let query = commandArgs .filter { !$0.hasPrefix("-") } .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) let listPayload = try client.sendV2(method: "workspace.list") let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? [] var matches: [[String: Any]] = [] for ws in workspaces { let title = (ws["title"] as? String) ?? "" let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query) var contentMatch = false if includeContent && !query.isEmpty, let wsId = ws["id"] as? String { let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId]) let text = (textPayload?["text"] as? String) ?? "" contentMatch = text.localizedCaseInsensitiveContains(query) } if titleMatch || contentMatch { matches.append(ws) } } if shouldSelect, let first = matches.first, let wsId = first["id"] as? String { _ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId]) } if jsonOutput { let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any] print(jsonString(["matches": formatted?["matches"] ?? []])) } else if matches.isEmpty { print("No matches") } else { for item in matches { let handle = textHandle(item, idFormat: idFormat) let title = (item["title"] as? String) ?? "" print("\(handle) \"\(title)\"") } } case "clear-history": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let surfaceArg = optionValue(commandArgs, name: "--surface") var params: [String: Any] = [:] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.clear_history", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat)) case "set-hook": var store = loadTmuxCompatStore() if commandArgs.contains("--list") { if jsonOutput { print(jsonString(["hooks": store.hooks])) } else if store.hooks.isEmpty { print("No hooks configured") } else { for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) { print("\(event) -> \(hookCmd)") } } return } if commandArgs.contains("--unset") { guard let event = commandArgs.last else { throw CLIError(message: "set-hook --unset requires an event name") } store.hooks.removeValue(forKey: event) try saveTmuxCompatStore(store) print("OK") return } guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else { throw CLIError(message: "set-hook requires <event> <command>") } let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !commandText.isEmpty else { throw CLIError(message: "set-hook requires <event> <command>") } store.hooks[event] = commandText try saveTmuxCompatStore(store) print("OK") case "popup": throw CLIError(message: "popup is not supported yet in cmux CLI parity mode") case "bind-key", "unbind-key", "copy-mode": throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode") case "set-buffer": let (nameArg, rem0) = parseOption(commandArgs, name: "--name") let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default" let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { throw CLIError(message: "set-buffer requires text") } var store = loadTmuxCompatStore() store.buffers[name] = content try saveTmuxCompatStore(store) print("OK") case "list-buffers": let store = loadTmuxCompatStore() if jsonOutput { let payload = store.buffers.map { key, value in ["name": key, "size": value.count] } print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }])) } else if store.buffers.isEmpty { print("No buffers") } else { for key in store.buffers.keys.sorted() { let size = store.buffers[key]?.count ?? 0 print("\(key)\t\(size)") } } case "paste-buffer": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let surfaceArg = optionValue(commandArgs, name: "--surface") let name = optionValue(commandArgs, name: "--name") ?? "default" let store = loadTmuxCompatStore() guard let buffer = store.buffers[name] else { throw CLIError(message: "Buffer not found: \(name)") } var params: [String: Any] = ["text": buffer] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_text", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "respawn-pane": let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride) let surfaceArg = optionValue(commandArgs, name: "--surface") let (commandOpt, rem0) = parseOption(commandArgs, name: "--command") let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines) let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText var params: [String: Any] = ["text": finalCommand + "\n"] let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true) if let wsId { params["workspace_id"] = wsId } let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true) if let sfId { params["surface_id"] = sfId } let payload = try client.sendV2(method: "surface.send_text", params: params) printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK") case "display-message": let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print") let message = commandArgs .filter { !$0.hasPrefix("-") } .joined(separator: " ") .trimmingCharacters(in: .whitespacesAndNewlines) guard !message.isEmpty else { throw CLIError(message: "display-message requires text") } if printOnly { print(message) return } let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message]) if jsonOutput { print(jsonString(payload)) } else { print(message) } default: throw CLIError(message: "Unsupported tmux compatibility command: \(command)") } } private func runClaudeHook( commandArgs: [String], client: SocketClient, telemetry: CLISocketSentryTelemetry ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") let workspaceArg = hookWsFlag ?? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil) let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore() telemetry.breadcrumb( "claude-hook.input", data: [ "subcommand": subcommand, "has_session_id": parsedInput.sessionId != nil, "has_workspace_flag": hookWsFlag != nil, "has_surface_flag": optionValue(hookArgs, name: "--surface") != nil ] ) switch subcommand { case "session-start", "active": telemetry.breadcrumb("claude-hook.session-start") let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: nil, fallback: workspaceArg, client: client ) let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: nil, fallback: surfaceArg, workspaceId: workspaceId, client: client ) let claudePid: Int? = { guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]? .trimmingCharacters(in: .whitespacesAndNewlines), let pid = Int(raw), pid > 0 else { return nil } return pid }() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, pid: claudePid ) } // Register PID for stale-session detection and OSC suppression, // but don't set a visible status. "Running" only appears when the // user submits a prompt (UserPromptSubmit) or Claude starts working // (PreToolUse). if let claudePid { _ = try? sendV1Command( "set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)", client: client ) } print("OK") case "stop", "idle": telemetry.breadcrumb("claude-hook.stop") do { // Turn ended. Don't consume session or clear PID — Claude is still alive. // Notification hook handles user-facing notifications; SessionEnd handles cleanup. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, fallback: surfaceArg, workspaceId: workspaceId, client: client ) // Update session with transcript summary and send completion notification. let completion = summarizeClaudeHookStop( parsedInput: parsedInput, sessionRecord: mappedSession ) if let sessionId = parsedInput.sessionId, let completion { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, lastSubtitle: completion.subtitle, lastBody: completion.body ) } if let completion { let title = "Claude Code" let subtitle = sanitizeNotificationField(completion.subtitle) let body = sanitizeNotificationField(completion.body) let payload = "\(title)|\(subtitle)|\(body)" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) } try? setClaudeStatus( client: client, workspaceId: workspaceId, value: "Idle", icon: "pause.circle.fill", color: "#8E8E93" ) print("OK") } catch { if shouldIgnoreClaudeHookTeardownError(error) { telemetry.breadcrumb("claude-hook.stop.ignored", data: ["error": String(describing: error)]) print("OK") return } throw error } case "prompt-submit": telemetry.breadcrumb("claude-hook.prompt-submit") let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) _ = try sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setClaudeStatus( client: client, workspaceId: workspaceId, value: "Running", icon: "bolt.fill", color: "#4C8DFF" ) print("OK") case "notification", "notify": telemetry.breadcrumb("claude-hook.notification") var summary = summarizeClaudeHookNotification(rawInput: rawInput) let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) if let mappedSession, let savedBody = mappedSession.lastBody, !savedBody.isEmpty, summary.body.contains("needs your attention") || summary.body.contains("needs your input") { summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody) } let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, fallback: surfaceArg, workspaceId: workspaceId, client: client ) let title = "Claude Code" let subtitle = sanitizeNotificationField(summary.subtitle) let body = sanitizeNotificationField(summary.body) let payload = "\(title)|\(subtitle)|\(body)" if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd, lastSubtitle: summary.subtitle, lastBody: summary.body ) } let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)") _ = try? setClaudeStatus( client: client, workspaceId: workspaceId, value: "Needs input", icon: "bell.fill", color: "#4C8DFF" ) print(response) case "session-end": telemetry.breadcrumb("claude-hook.session-end") // Final cleanup when Claude process exits. // Only clear when we are the primary cleanup path (Stop didn't fire first). // If Stop already consumed the session, consumedSession is nil and we skip // to avoid wiping the completion notification that Stop just delivered. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) let fallbackSurfaceId: String? = { guard let fallbackWorkspaceId else { return nil } return try? resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, fallback: surfaceArg, workspaceId: fallbackWorkspaceId, client: client ) }() let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, surfaceId: fallbackSurfaceId ) if let consumedSession { let workspaceId = consumedSession.workspaceId _ = try? clearClaudeStatus(client: client, workspaceId: workspaceId) _ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client) _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) } print("OK") case "pre-tool-use": telemetry.breadcrumb("claude-hook.pre-tool-use") // Clears "Needs input" status and notification when Claude resumes work // (e.g. after permission grant). Runs async so it doesn't block tool execution. let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) let claudePid = mappedSession?.pid // AskUserQuestion means Claude is about to ask the user something. // Save question text in session so the Notification handler can use it // instead of the generic "Claude Code needs your attention". if let toolName = parsedInput.object?["tool_name"] as? String, toolName == "AskUserQuestion", let question = describeAskUserQuestion(parsedInput.object), let sessionId = parsedInput.sessionId { // Preserve the existing surfaceId from SessionStart; passing "" // would overwrite it and cause notifications to target the wrong workspace. let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? "" try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: existingSurfaceId, cwd: parsedInput.cwd, lastSubtitle: "Waiting", lastBody: question ) // Don't clear notifications or set status here. // The Notification hook fires right after and will use the saved question. print("OK") return } _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) let statusValue: String if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"), let toolStatus = describeToolUse(parsedInput.object) { statusValue = toolStatus } else { statusValue = "Running" } try setClaudeStatus( client: client, workspaceId: workspaceId, value: statusValue, icon: "bolt.fill", color: "#4C8DFF", pid: claudePid ) print("OK") case "help", "--help", "-h": telemetry.breadcrumb("claude-hook.help") print( """ cmux claude-hook <session-start|stop|session-end|notification|prompt-submit|pre-tool-use> [--workspace <id|index>] [--surface <id|index>] """ ) default: throw CLIError(message: "Unknown claude-hook subcommand: \(subcommand)") } } private func setClaudeStatus( client: SocketClient, workspaceId: String, value: String, icon: String, color: String, pid: Int? = nil ) throws { var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" if let pid { cmd += " --pid=\(pid)" } _ = try client.send(command: cmd) } private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws { _ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)") } private func resolvePreferredWorkspaceIdForClaudeHook( preferred: String?, fallback: String?, client: SocketClient ) throws -> String { if let preferred = nonEmptyClaudeHookIdentifier(preferred) { if isUUID(preferred) { return preferred } return try resolveWorkspaceIdForClaudeHook(preferred, client: client) } if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) { return fallback } return try resolveWorkspaceIdForClaudeHook(fallback, client: client) } private func resolvePreferredSurfaceIdForClaudeHook( preferred: String?, fallback: String?, workspaceId: String, client: SocketClient ) throws -> String { if let preferred = nonEmptyClaudeHookIdentifier(preferred) { if isUUID(preferred) { return preferred } return try resolveSurfaceIdForClaudeHook(preferred, workspaceId: workspaceId, client: client) } if let fallback = nonEmptyClaudeHookIdentifier(fallback), isUUID(fallback) { return fallback } return try resolveSurfaceIdForClaudeHook(fallback, workspaceId: workspaceId, client: client) } private func nonEmptyClaudeHookIdentifier(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil } return trimmed } private func shouldIgnoreClaudeHookTeardownError(_ error: Error) -> Bool { let message = String(describing: error).lowercased() let benignFragments = [ "tabmanager not available", "no workspace selected", "workspace not found", "workspace ref not found", "workspace index not found", "surface not found", "surface ref not found", "surface index not found", "unable to resolve surface id", "panel not found", "tab not found", "failed to write to socket", "socket read error", "not connected" ] return benignFragments.contains { message.contains($0) } } private func describeAskUserQuestion(_ object: [String: Any]?) -> String? { guard let object, let input = object["tool_input"] as? [String: Any], let questions = input["questions"] as? [[String: Any]], let first = questions.first else { return nil } var parts: [String] = [] if let question = first["question"] as? String, !question.isEmpty { parts.append(question) } else if let header = first["header"] as? String, !header.isEmpty { parts.append(header) } if let options = first["options"] as? [[String: Any]] { let labels = options.compactMap { $0["label"] as? String } if !labels.isEmpty { parts.append(labels.map { "[\($0)]" }.joined(separator: " ")) } } if parts.isEmpty { return "Asking a question" } return parts.joined(separator: "\n") } private func describeToolUse(_ object: [String: Any]?) -> String? { guard let object, let toolName = object["tool_name"] as? String else { return nil } let input = object["tool_input"] as? [String: Any] switch toolName { case "Read": if let path = input?["file_path"] as? String { return "Reading \(shortenPath(path))" } return "Reading file" case "Edit": if let path = input?["file_path"] as? String { return "Editing \(shortenPath(path))" } return "Editing file" case "Write": if let path = input?["file_path"] as? String { return "Writing \(shortenPath(path))" } return "Writing file" case "Bash": if let cmd = input?["command"] as? String { let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd let short = String(first.prefix(30)) return "Running \(short)" } return "Running command" case "Glob": if let pattern = input?["pattern"] as? String { return "Searching \(String(pattern.prefix(30)))" } return "Searching files" case "Grep": if let pattern = input?["pattern"] as? String { return "Grep \(String(pattern.prefix(30)))" } return "Searching code" case "Agent": if let desc = input?["description"] as? String { return String(desc.prefix(40)) } return "Subagent" case "WebFetch": return "Fetching URL" case "WebSearch": if let query = input?["query"] as? String { return "Search: \(String(query.prefix(30)))" } return "Web search" default: return toolName } } private func shortenPath(_ path: String) -> String { let url = URL(fileURLWithPath: path) let name = url.lastPathComponent return name.isEmpty ? String(path.suffix(30)) : name } private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String { try resolveWorkspaceIdAllowingFallback(raw, client: client) } private func resolveSurfaceIdForClaudeHook( _ raw: String?, workspaceId: String, client: SocketClient ) throws -> String { try resolveSurfaceIdAllowingFallback(raw, workspaceId: workspaceId, client: client) } private func resolveWorkspaceIdAllowingFallback( _ raw: String?, client: SocketClient ) throws -> String { if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client), (try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])) != nil { return candidate } if let callerWorkspaceId = resolveCallerWorkspaceIdByTTY(client: client), (try? client.sendV2(method: "surface.list", params: ["workspace_id": callerWorkspaceId])) != nil { return callerWorkspaceId } return try resolveWorkspaceId(nil, client: client) } private func resolveSurfaceIdAllowingFallback( _ raw: String?, workspaceId: String, client: SocketClient ) throws -> String { if let raw, !raw.isEmpty, let candidate = try? resolveSurfaceId(raw, workspaceId: workspaceId, client: client), let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { let items = listed["surfaces"] as? [[String: Any]] ?? [] if items.contains(where: { ($0["id"] as? String) == candidate || ($0["ref"] as? String) == candidate }) { return candidate } } if let callerSurfaceId = resolveCallerSurfaceIdByTTY(workspaceId: workspaceId, client: client), let listed = try? client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId]) { let items = listed["surfaces"] as? [[String: Any]] ?? [] if items.contains(where: { ($0["id"] as? String) == callerSurfaceId || ($0["ref"] as? String) == callerSurfaceId }) { return callerSurfaceId } } return try resolveSurfaceId(nil, workspaceId: workspaceId, client: client) } private struct CallerTerminalBinding { let workspaceId: String let surfaceId: String } private func resolveCallerWorkspaceIdByTTY(client: SocketClient) -> String? { resolveCallerTerminalBindingByTTY(client: client)?.workspaceId } private func resolveCallerSurfaceIdByTTY(workspaceId: String, client: SocketClient) -> String? { guard let binding = resolveCallerTerminalBindingByTTY(client: client), binding.workspaceId == workspaceId else { return nil } return binding.surfaceId } private func resolveCallerTerminalBindingByTTY(client: SocketClient) -> CallerTerminalBinding? { guard let ttyName = resolveCallerTTYName() else { return nil } guard let payload = try? client.sendV2(method: "debug.terminals") else { return nil } let terminals = payload["terminals"] as? [[String: Any]] ?? [] for terminal in terminals { guard normalizedTTYName(terminal["tty"] as? String) == ttyName, let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String), let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else { continue } return CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId) } return nil } private func resolveCallerTTYName() -> String? { let env = ProcessInfo.processInfo.environment for key in ["CMUX_CLI_TTY_NAME", "CMUX_TTY_NAME", "TTY", "SSH_TTY"] { if let ttyName = normalizedTTYName(env[key]) { return ttyName } } for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] { if let rawTTYName = ttyname(fileDescriptor), let ttyName = normalizedTTYName(String(cString: rawTTYName)) { return ttyName } } return nil } private func normalizedTTYName(_ raw: String?) -> String? { guard let trimmed = normalizedHandleValue(raw == "not a tty" ? nil : raw) else { return nil } let components = trimmed.split(separator: "/") if let last = components.last, !last.isEmpty { return String(last) } return trimmed } private func normalizedHandleValue(_ raw: String?) -> String? { guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } return raw } private func parseClaudeHookInput(rawInput: String) -> ClaudeHookParsedInput { let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []), let object = json as? [String: Any] else { return ClaudeHookParsedInput(rawInput: rawInput, object: nil, sessionId: nil, cwd: nil, transcriptPath: nil) } let sessionId = extractClaudeHookSessionId(from: object) let cwd = extractClaudeHookCWD(from: object) let transcriptPath = firstString(in: object, keys: ["transcript_path", "transcriptPath"]) return ClaudeHookParsedInput(rawInput: rawInput, object: object, sessionId: sessionId, cwd: cwd, transcriptPath: transcriptPath) } private func extractClaudeHookSessionId(from object: [String: Any]) -> String? { if let id = firstString(in: object, keys: ["session_id", "sessionId"]) { return id } if let nested = object["notification"] as? [String: Any], let id = firstString(in: nested, keys: ["session_id", "sessionId"]) { return id } if let nested = object["data"] as? [String: Any], let id = firstString(in: nested, keys: ["session_id", "sessionId"]) { return id } if let session = object["session"] as? [String: Any], let id = firstString(in: session, keys: ["id", "session_id", "sessionId"]) { return id } if let context = object["context"] as? [String: Any], let id = firstString(in: context, keys: ["session_id", "sessionId"]) { return id } return nil } private func extractClaudeHookCWD(from object: [String: Any]) -> String? { let cwdKeys = ["cwd", "working_directory", "workingDirectory", "project_dir", "projectDir"] if let cwd = firstString(in: object, keys: cwdKeys) { return cwd } if let nested = object["notification"] as? [String: Any], let cwd = firstString(in: nested, keys: cwdKeys) { return cwd } if let nested = object["data"] as? [String: Any], let cwd = firstString(in: nested, keys: cwdKeys) { return cwd } if let context = object["context"] as? [String: Any], let cwd = firstString(in: context, keys: cwdKeys) { return cwd } return nil } private func summarizeClaudeHookStop( parsedInput: ClaudeHookParsedInput, sessionRecord: ClaudeHookSessionRecord? ) -> (subtitle: String, body: String)? { let cwd = parsedInput.cwd ?? sessionRecord?.cwd let transcriptPath = parsedInput.transcriptPath let projectName: String? = { guard let cwd = cwd, !cwd.isEmpty else { return nil } let path = NSString(string: cwd).expandingTildeInPath let tail = URL(fileURLWithPath: path).lastPathComponent return tail.isEmpty ? path : tail }() // Try reading the transcript JSONL for a richer summary. let transcript = transcriptPath.flatMap { readTranscriptSummary(path: $0) } if let lastMsg = transcript?.lastAssistantMessage { var subtitle = "Completed" if let projectName, !projectName.isEmpty { subtitle = "Completed in \(projectName)" } return (subtitle, truncate(lastMsg, maxLength: 200)) } // Fallback: use session record data. let lastMessage = sessionRecord?.lastBody ?? sessionRecord?.lastSubtitle let hasContext = cwd != nil || lastMessage != nil guard hasContext else { return nil } var body = "Claude session completed" if let projectName, !projectName.isEmpty { body += " in \(projectName)" } if let lastMessage, !lastMessage.isEmpty { body += ". Last: \(lastMessage)" } return ("Completed", body) } private struct TranscriptSummary { let lastAssistantMessage: String? } private func readTranscriptSummary(path: String) -> TranscriptSummary? { let expandedPath = NSString(string: path).expandingTildeInPath guard let data = try? Data(contentsOf: URL(fileURLWithPath: expandedPath)) else { return nil } guard let content = String(data: data, encoding: .utf8) else { return nil } let lines = content.components(separatedBy: "\n") var lastAssistantMessage: String? for line in lines { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let lineData = trimmed.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any], let message = obj["message"] as? [String: Any], let role = message["role"] as? String, role == "assistant" else { continue } let text = extractMessageText(from: message) guard let text, !text.isEmpty else { continue } lastAssistantMessage = truncate(normalizedSingleLine(text), maxLength: 120) } guard lastAssistantMessage != nil else { return nil } return TranscriptSummary(lastAssistantMessage: lastAssistantMessage) } private func extractMessageText(from message: [String: Any]) -> String? { if let content = message["content"] as? String { return content.trimmingCharacters(in: .whitespacesAndNewlines) } if let contentArray = message["content"] as? [[String: Any]] { let texts = contentArray.compactMap { block -> String? in guard (block["type"] as? String) == "text", let text = block["text"] as? String else { return nil } return text.trimmingCharacters(in: .whitespacesAndNewlines) } let joined = texts.joined(separator: " ") return joined.isEmpty ? nil : joined } return nil } private func summarizeClaudeHookNotification(rawInput: String) -> (subtitle: String, body: String) { let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return ("Waiting", "Claude is waiting for your input") } guard let data = trimmed.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data, options: []), let object = json as? [String: Any] else { let fallback = truncate(normalizedSingleLine(trimmed), maxLength: 180) return classifyClaudeNotification(signal: fallback, message: fallback) } let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] let signalParts = [ firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]), firstString(in: object, keys: ["notification_type", "matcher", "reason"]), firstString(in: nested, keys: ["type", "kind", "reason"]) ] let messageCandidates = [ firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]), firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"]) ] let session = firstString(in: object, keys: ["session_id", "sessionId"]) let message = messageCandidates.compactMap { $0 }.first ?? "Claude needs your input" let normalizedMessage = normalizedSingleLine(message) let signal = signalParts.compactMap { $0 }.joined(separator: " ") var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage) classified.body = truncate(classified.body, maxLength: 180) return classified } private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) { let lower = "\(signal) \(message)".lowercased() if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { let body = message.isEmpty ? "Approval needed" : message return ("Permission", body) } if lower.contains("error") || lower.contains("failed") || lower.contains("exception") { let body = message.isEmpty ? "Claude reported an error" : message return ("Error", body) } if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") { let body = message.isEmpty ? "Task completed" : message return ("Completed", body) } if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") { let body = message.isEmpty ? "Waiting for input" : message return ("Waiting", body) } // Use the message directly if it's meaningful (not a generic placeholder). if !message.isEmpty, message != "Claude needs your input" { return ("Attention", message) } return ("Attention", "Claude needs your attention") } private func firstString(in object: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = object[key] else { continue } if let string = value as? String { let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } } return nil } private func normalizedSingleLine(_ value: String) -> String { let collapsed = value.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) return collapsed.trimmingCharacters(in: .whitespacesAndNewlines) } private func truncate(_ value: String, maxLength: Int) -> String { guard value.count > maxLength else { return value } let index = value.index(value.startIndex, offsetBy: max(0, maxLength - 1)) return String(value[..<index]) + "…" } private func sanitizeNotificationField(_ value: String) -> String { return normalizedSingleLine(value) .replacingOccurrences(of: "|", with: "¦") } // MARK: - Codex hooks /// The hooks.json content that cmux installs into ~/.codex/. /// Each hook calls `cmux codex-hook <event>` which gracefully no-ops /// when not running inside cmux. The command checks for cmux on PATH /// first so it silently succeeds even when cmux is not installed /// (e.g. user opened codex in a non-cmux terminal). private static func codexHookCommand(_ event: String) -> String { "[ -n \"$CMUX_SURFACE_ID\" ] && command -v cmux >/dev/null 2>&1 && cmux codex-hook \(event) || echo '{}'" } private static let codexHooksJSON: [String: Any] = [ "hooks": [ "SessionStart": [[ "hooks": [[ "type": "command", "command": codexHookCommand("session-start"), "timeout": 10 ] as [String: Any]] ] as [String: Any]], "UserPromptSubmit": [[ "hooks": [[ "type": "command", "command": codexHookCommand("prompt-submit"), "timeout": 10 ] as [String: Any]] ] as [String: Any]], "Stop": [[ "hooks": [[ "type": "command", "command": codexHookCommand("stop"), "timeout": 10 ] as [String: Any]] ] as [String: Any]] ] as [String: Any] ] /// Identifier used to detect cmux-owned hooks during uninstall. private static let codexHookCommandMarker = "cmux codex-hook" private func runCodexInstallHooks() throws { let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") || ProcessInfo.processInfo.arguments.contains("-y") let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] ?? NSString(string: "~/.codex").expandingTildeInPath let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") let configPath = (codexHome as NSString).appendingPathComponent("config.toml") let fm = FileManager.default // Ensure ~/.codex/ exists try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil) // Read existing state let existingHooksContent: String? = fm.fileExists(atPath: hooksPath) ? (try? String(contentsOfFile: hooksPath, encoding: .utf8)) : nil // Build merged hooks var existing: [String: Any] = [:] if let existingHooksContent, let data = existingHooksContent.data(using: .utf8), let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { existing = parsed } var hooks = existing["hooks"] as? [String: Any] ?? [:] let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any] for (eventName, cmuxGroups) in cmuxHooks { guard let cmuxGroupArray = cmuxGroups as? [[String: Any]] else { continue } var eventGroups = hooks[eventName] as? [[String: Any]] ?? [] eventGroups.removeAll { group in guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } return groupHooks.allSatisfy { hook in (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true } } eventGroups.append(contentsOf: cmuxGroupArray) hooks[eventName] = eventGroups } existing["hooks"] = hooks let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys]) let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" // Build new config.toml content let existingConfigContent: String = fm.fileExists(atPath: configPath) ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") : "" let newConfigContent = buildConfigWithCodexHooks(existingConfigContent) // Check if anything would change let hooksChanged = existingHooksContent != newHooksContent let configChanged = existingConfigContent != newConfigContent if !hooksChanged && !configChanged { print("cmux hooks are already installed. Nothing to change.") return } // Show diff and ask for confirmation if hooksChanged { print(" \(hooksPath):") if let existingHooksContent { printSimpleDiff(old: existingHooksContent, new: newHooksContent) } else { print(" (new file)") let lines = newHooksContent.components(separatedBy: "\n") for (i, line) in lines.enumerated() { let lineLabel = String(format: "%3d", i + 1) print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") } } print("") } if configChanged { print(" \(configPath):") if existingConfigContent.isEmpty { print(" (new file)") let lines = newConfigContent.components(separatedBy: "\n") for (i, line) in lines.enumerated() where !line.isEmpty { let lineLabel = String(format: "%3d", i + 1) print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m") } } else { printSimpleDiff(old: existingConfigContent, new: newConfigContent) } print("") } if !skipConfirm { print("Apply these changes? [Y/n] ", terminator: "") if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !response.isEmpty && response != "y" && response != "yes" { print("Aborted.") return } } // Apply changes if hooksChanged { try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) } if configChanged { try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) } print("") print("Installed. Hooks activate inside cmux and silently no-op elsewhere.") print("To remove: cmux codex uninstall-hooks") } private func runCodexUninstallHooks() throws { let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") || ProcessInfo.processInfo.arguments.contains("-y") let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"] ?? NSString(string: "~/.codex").expandingTildeInPath let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json") let configPath = (codexHome as NSString).appendingPathComponent("config.toml") let fm = FileManager.default guard fm.fileExists(atPath: hooksPath), let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)), var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { print("No hooks.json found at \(hooksPath)") return } guard var hooks = parsed["hooks"] as? [String: Any] else { print("No hooks section found in \(hooksPath)") return } // Build the new state without cmux hooks var removedCount = 0 for eventName in hooks.keys { guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue } let before = eventGroups.count eventGroups.removeAll { group in guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false } return groupHooks.allSatisfy { hook in (hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true } } removedCount += before - eventGroups.count if eventGroups.isEmpty { hooks.removeValue(forKey: eventName) } else { hooks[eventName] = eventGroups } } // Build config.toml without codex_hooks let existingConfigContent: String = fm.fileExists(atPath: configPath) ? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "") : "" let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent) let configChanged = existingConfigContent != newConfigContent if removedCount == 0 && !configChanged { print("No cmux hooks found.") return } parsed["hooks"] = hooks let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys]) let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? "" let oldHooksContent = String(data: data, encoding: .utf8) ?? "" // Show diff and ask for confirmation if removedCount > 0 { print(" \(hooksPath):") printSimpleDiff(old: oldHooksContent, new: newHooksContent) print("") } if configChanged { print(" \(configPath):") printSimpleDiff(old: existingConfigContent, new: newConfigContent) print("") } if !skipConfirm { print("Apply these changes? [Y/n] ", terminator: "") if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(), !response.isEmpty && response != "y" && response != "yes" { print("Aborted.") return } } if removedCount > 0 { try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic) } if configChanged { try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8) } print("Removed cmux Codex hooks.") } /// Print a unified-diff-style view with context lines and line numbers. private func printSimpleDiff(old: String, new: String, contextLines: Int = 2) { let red = "\u{001B}[31m" let green = "\u{001B}[32m" let dim = "\u{001B}[2m" let reset = "\u{001B}[0m" let oldLines = old.components(separatedBy: "\n") let newLines = new.components(separatedBy: "\n") // Simple LCS-based diff: find matching lines let lcs = longestCommonSubsequence(oldLines, newLines) var oldIdx = 0, newIdx = 0, lcsIdx = 0 struct DiffLine { enum Kind { case context, remove, add } let kind: Kind let lineNo: Int // 1-based, refers to old line for context/remove, new line for add let text: String } var allDiffs: [DiffLine] = [] while oldIdx < oldLines.count || newIdx < newLines.count { if lcsIdx < lcs.count && oldIdx < oldLines.count && newIdx < newLines.count && oldLines[oldIdx] == lcs[lcsIdx] && newLines[newIdx] == lcs[lcsIdx] { allDiffs.append(DiffLine(kind: .context, lineNo: newIdx + 1, text: newLines[newIdx])) oldIdx += 1; newIdx += 1; lcsIdx += 1 } else if oldIdx < oldLines.count && (lcsIdx >= lcs.count || oldLines[oldIdx] != lcs[lcsIdx]) { allDiffs.append(DiffLine(kind: .remove, lineNo: oldIdx + 1, text: oldLines[oldIdx])) oldIdx += 1 } else if newIdx < newLines.count { allDiffs.append(DiffLine(kind: .add, lineNo: newIdx + 1, text: newLines[newIdx])) newIdx += 1 } } // Find ranges with changes and expand by context var changedIndices = Set<Int>() for (i, d) in allDiffs.enumerated() where d.kind != .context { for j in max(0, i - contextLines)...min(allDiffs.count - 1, i + contextLines) { changedIndices.insert(j) } } var lastPrinted = -1 for i in changedIndices.sorted() { if lastPrinted >= 0 && i > lastPrinted + 1 { print(" \(dim)...\(reset)") } let d = allDiffs[i] let lineLabel = String(format: "%3d", d.lineNo) switch d.kind { case .context: print(" \(dim)\(lineLabel) \(d.text)\(reset)") case .remove: print(" \(red)\(lineLabel) -\(d.text)\(reset)") case .add: print(" \(green)\(lineLabel) +\(d.text)\(reset)") } lastPrinted = i } } private func longestCommonSubsequence(_ a: [String], _ b: [String]) -> [String] { let m = a.count, n = b.count var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1) for i in 1...m { for j in 1...n { if a[i - 1] == b[j - 1] { dp[i][j] = dp[i - 1][j - 1] + 1 } else { dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) } } } var result: [String] = [] var i = m, j = n while i > 0 && j > 0 { if a[i - 1] == b[j - 1] { result.append(a[i - 1]) i -= 1; j -= 1 } else if dp[i - 1][j] > dp[i][j - 1] { i -= 1 } else { j -= 1 } } return result.reversed() } /// Returns config.toml content with codex_hooks = true under [features]. private func buildConfigWithCodexHooks(_ content: String) -> String { var lines = content.components(separatedBy: "\n") // Check if codex_hooks key already exists (exact key match at line start) if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) { lines[idx] = "codex_hooks = true" return lines.joined(separator: "\n") } // Find [features] section and insert after it (first occurrence only) if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { lines.insert("codex_hooks = true", at: idx + 1) return lines.joined(separator: "\n") } // No [features] section, append one var result = content if !result.isEmpty && !result.hasSuffix("\n") { result += "\n" } result += "\n[features]\ncodex_hooks = true\n" return result } /// Returns config.toml content with codex_hooks removed from [features]. private func buildConfigWithoutCodexHooks(_ content: String) -> String { var lines = content.components(separatedBy: "\n") // Remove the codex_hooks line lines.removeAll { isTomlKey($0, key: "codex_hooks") } // If [features] section is now empty (only has the header, nothing before next section or EOF), // remove the header too if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) { let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[") if sectionEmpty { lines.remove(at: idx) } } return lines.joined(separator: "\n") } /// Check if a TOML line sets a specific key (ignoring comments and whitespace). private func isTomlKey(_ line: String, key: String) -> Bool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.hasPrefix("#") else { return false } guard trimmed.hasPrefix(key) else { return false } let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces) return rest.hasPrefix("=") } /// Codex hook handler. Gracefully no-ops when not running inside cmux. private func runCodexHook( commandArgs: [String], client: SocketClient, telemetry: CLISocketSentryTelemetry ) throws { let env = ProcessInfo.processInfo.environment // Graceful no-op: if not inside cmux, exit silently with valid JSON guard env["CMUX_SURFACE_ID"] != nil else { print("{}") return } let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"] let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil) let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? "" let parsedInput = parseClaudeHookInput(rawInput: rawInput) let sessionStore = ClaudeHookSessionStore( processEnv: env.merging( ["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/codex-hook-sessions.json"], uniquingKeysWith: { _, new in new } ) ) telemetry.breadcrumb( "codex-hook.input", data: [ "subcommand": subcommand, "has_session_id": parsedInput.sessionId != nil ] ) switch subcommand { case "session-start": telemetry.breadcrumb("codex-hook.session-start") let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: nil, fallback: workspaceArg, client: client ) let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: nil, fallback: surfaceArg, workspaceId: workspaceId, client: client ) if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: parsedInput.cwd ) } print("{}") case "prompt-submit": telemetry.breadcrumb("codex-hook.prompt-submit") let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) _ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client) try setCodexStatus( client: client, workspaceId: workspaceId, value: "Running", icon: "bolt.fill", color: "#4C8DFF" ) print("{}") case "stop": telemetry.breadcrumb("codex-hook.stop") do { let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( preferred: mappedSession?.workspaceId, fallback: workspaceArg, client: client ) let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( preferred: mappedSession?.surfaceId, fallback: surfaceArg, workspaceId: workspaceId, client: client ) // Build completion notification from Codex stop payload let lastMessage = parsedInput.object?["last_assistant_message"] as? String ?? parsedInput.object?["lastAssistantMessage"] as? String let cwd = parsedInput.cwd ?? mappedSession?.cwd let projectName: String? = { guard let cwd, !cwd.isEmpty else { return nil } return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent }() if let sessionId = parsedInput.sessionId { try? sessionStore.upsert( sessionId: sessionId, workspaceId: workspaceId, surfaceId: surfaceId, cwd: cwd, lastSubtitle: "Completed", lastBody: lastMessage.map { truncate($0, maxLength: 200) } ) } // Send completion notification var subtitle = "Completed" if let projectName, !projectName.isEmpty { subtitle = "Completed in \(projectName)" } let body = sanitizeNotificationField( lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) } ?? "Codex session completed" ) let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(body)" _ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client) try? setCodexStatus( client: client, workspaceId: workspaceId, value: "Idle", icon: "pause.circle.fill", color: "#8E8E93" ) print("{}") } catch { if shouldIgnoreClaudeHookTeardownError(error) { telemetry.breadcrumb("codex-hook.stop.ignored", data: ["error": String(describing: error)]) print("{}") return } throw error } case "help", "--help", "-h": print("cmux codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]") default: throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)") } } private func setCodexStatus( client: SocketClient, workspaceId: String, value: String, icon: String, color: String ) throws { let cmd = "set_status codex \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)" _ = try client.send(command: cmd) } private func versionSummary() -> String { let info = resolvedVersionInfo() let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) } let baseSummary: String if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] { baseSummary = "cmux \(version) (\(build))" } else if let version = info["CFBundleShortVersionString"] { baseSummary = "cmux \(version)" } else if let build = info["CFBundleVersion"] { baseSummary = "cmux build \(build)" } else { baseSummary = "cmux version unknown" } guard let commit else { return baseSummary } return "\(baseSummary) [\(commit)]" } private func printWelcome() { let reset = "\u{001B}[0m" let bold = "\u{001B}[1m" func trueColor(_ red: Int, _ green: Int, _ blue: Int) -> String { "\u{001B}[38;2;\(red);\(green);\(blue)m" } let isDark = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" let c1 = trueColor(0, 212, 255) let c2 = trueColor(24, 181, 250) let c3 = trueColor(48, 150, 245) let c4 = trueColor(72, 119, 241) let c5 = trueColor(96, 88, 239) let c6 = trueColor(110, 73, 238) let c7 = trueColor(124, 58, 237) let tagline: String let subdued: String if isDark { tagline = trueColor(130, 130, 140) subdued = "\u{001B}[2m" } else { tagline = trueColor(90, 90, 98) subdued = trueColor(100, 100, 108) } let logo = """ \(c1) ::\(reset) \(c2) ::::\(reset) \(c1)c\(c2)m\(c3)u\(c7)x\(reset) \(c3) ::::::\(reset) \(c4) ::::::\(reset) \(tagline)the open source terminal\(reset) \(c5) ::::::\(reset) \(tagline)built for coding agents\(reset) \(c6) ::::\(reset) \(c7) ::\(reset) """ let shortcuts = """ \(bold)Shortcuts\(reset) \(bold)\u{2318}N\(reset)\(subdued) New workspace\(reset) \(bold)\u{2318}T\(reset)\(subdued) New tab\(reset) \(bold)\u{2318}P\(reset)\(subdued) Go to workspace\(reset) \(bold)\u{2318}D\(reset)\(subdued) Split right\(reset) \(bold)\u{2318}\u{21E7}D\(reset)\(subdued) Split down\(reset) \(bold)\u{2318}\u{21E7}P\(reset)\(subdued) Command palette\(reset) \(bold)\u{2318}\u{21E7}R\(reset)\(subdued) Rename workspace\(reset) \(bold)\u{2318}\u{21E7}L\(reset)\(subdued) New browser\(reset) \(bold)\u{2318}\u{21E7}U\(reset)\(subdued) Jump to latest unread\(reset) """ print() print(logo) print() print(shortcuts) print() print(" \(bold)Docs\(reset)\(subdued) https://cmux.com/docs\(reset)") print(" \(bold)Discord\(reset)\(subdued) https://discord.gg/xsgFEVrWCZ\(reset)") print(" \(bold)GitHub\(reset)\(subdued) https://github.com/manaflow-ai/cmux (please leave a star ⭐)\(reset)") print(" \(bold)Email\(reset)\(subdued) founders@manaflow.com\(reset)") print() print(" \(subdued)Run \(reset)\(bold)cmux --help\(reset)\(subdued) for all commands.\(reset)") print(" \(subdued)Run \(reset)\(bold)cmux shortcuts\(reset)\(subdued) to edit shortcuts.\(reset)") print(" \(subdued)Run \(reset)\(bold)cmux feedback\(reset)\(subdued) to report a bug.\(reset)") print() } private func resolvedVersionInfo() -> [String: String] { var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { info.merge(main, uniquingKeysWith: { current, _ in current }) } let needsPlistFallback = info["CFBundleShortVersionString"] == nil || info["CFBundleVersion"] == nil || info["CMUXCommit"] == nil if needsPlistFallback { for plistURL in candidateInfoPlistURLs() { guard let data = try? Data(contentsOf: plistURL), let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil), let dictionary = raw as? [String: Any], let parsed = versionInfo(from: dictionary) else { continue } info.merge(parsed, uniquingKeysWith: { current, _ in current }) if info["CFBundleShortVersionString"] != nil, info["CFBundleVersion"] != nil, info["CMUXCommit"] != nil { break } } } let needsProjectFallback = info["CFBundleShortVersionString"] == nil || info["CFBundleVersion"] == nil || info["CMUXCommit"] == nil if needsProjectFallback, let fromProject = versionInfoFromProjectFile() { info.merge(fromProject, uniquingKeysWith: { current, _ in current }) } if info["CMUXCommit"] == nil, let commit = normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"]) { info["CMUXCommit"] = commit } return info } private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? { guard let dictionary else { return nil } var info: [String: String] = [:] if let version = dictionary["CFBundleShortVersionString"] as? String { let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && !trimmed.contains("$(") { info["CFBundleShortVersionString"] = trimmed } } if let build = dictionary["CFBundleVersion"] as? String { let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty && !trimmed.contains("$(") { info["CFBundleVersion"] = trimmed } } if let commit = dictionary["CMUXCommit"] as? String, let normalizedCommit = normalizedCommitHash(commit) { info["CMUXCommit"] = normalizedCommit } return info.isEmpty ? nil : info } private func versionInfoFromProjectFile() -> [String: String]? { guard let executableURL = resolvedExecutableURL() else { return nil } let fileManager = FileManager.default var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") if fileManager.fileExists(atPath: projectFile.path), let contents = try? String(contentsOf: projectFile, encoding: .utf8) { var info: [String: String] = [:] if let version = firstProjectSetting("MARKETING_VERSION", in: contents) { info["CFBundleShortVersionString"] = version } if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) { info["CFBundleVersion"] = build } if let commit = gitCommitHash(at: current) { info["CMUXCommit"] = commit } if !info.isEmpty { return info } } guard let parent = parentSearchURL(for: current) else { break } current = parent } return nil } private func firstProjectSetting(_ key: String, in source: String) -> String? { let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);" guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let searchRange = NSRange(source.startIndex..<source.endIndex, in: source) guard let match = regex.firstMatch(in: source, options: [], range: searchRange), match.numberOfRanges > 1, let valueRange = Range(match.range(at: 1), in: source) else { return nil } let value = source[valueRange] .trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) guard !value.isEmpty, !value.contains("$(") else { return nil } return value } private func gitCommitHash(at directory: URL) -> String? { let process = Process() let stdout = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = ["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"] process.standardOutput = stdout process.standardError = Pipe() do { try process.run() } catch { return nil } process.waitUntilExit() guard process.terminationStatus == 0 else { return nil } let data = stdout.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { return nil } return normalizedCommitHash(output) } private func normalizedCommitHash(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !trimmed.contains("$(") else { return nil } let normalized = trimmed.lowercased() let allowed = CharacterSet(charactersIn: "0123456789abcdef") guard normalized.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil } return String(normalized.prefix(12)) } // Foundation can walk past "/" into "/.." when repeatedly deleting path // components, so stop once the canonical root is reached. private func parentSearchURL(for url: URL) -> URL? { let standardized = url.standardizedFileURL let path = standardized.path guard !path.isEmpty, path != "/" else { return nil } let parent = standardized.deletingLastPathComponent().standardizedFileURL guard parent.path != path else { return nil } return parent } private func candidateInfoPlistURLs() -> [URL] { guard let executableURL = resolvedExecutableURL() else { return [] } let fileManager = FileManager.default var candidates: [URL] = [] var seen: Set<String> = [] func appendIfExisting(_ url: URL) { let path = url.path guard !path.isEmpty else { return } guard seen.insert(path).inserted else { return } guard fileManager.fileExists(atPath: path) else { return } candidates.append(url) } var current = executableURL.deletingLastPathComponent().standardizedFileURL while true { if current.pathExtension == "app" { appendIfExisting(current.appendingPathComponent("Contents/Info.plist")) } if current.lastPathComponent == "Contents" { appendIfExisting(current.appendingPathComponent("Info.plist")) } let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj") let repoInfo = current.appendingPathComponent("Resources/Info.plist") if fileManager.fileExists(atPath: projectMarker.path), fileManager.fileExists(atPath: repoInfo.path) { appendIfExisting(repoInfo) break } guard let parent = parentSearchURL(for: current) else { break } current = parent } // If we already found an ancestor bundle or repo Info.plist, avoid scanning // sibling app bundles. Large Resources directories can otherwise balloon RSS. guard candidates.isEmpty else { return candidates } let searchRoots = [ executableURL.deletingLastPathComponent().standardizedFileURL, executableURL.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL ] for root in searchRoots { guard let entries = fileManager.enumerator( at: root, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants], errorHandler: { _, _ in true } ) else { continue } for case let entry as URL in entries where entry.pathExtension == "app" { appendIfExisting(entry.appendingPathComponent("Contents/Info.plist")) } } return candidates } private func currentExecutablePath() -> String? { var size: UInt32 = 0 _ = _NSGetExecutablePath(nil, &size) if size > 0 { var buffer = Array<CChar>(repeating: 0, count: Int(size)) if _NSGetExecutablePath(&buffer, &size) == 0 { let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) if !path.isEmpty { return path } } } return Bundle.main.executableURL?.path ?? args.first } private func resolvedExecutableURL() -> URL? { guard let executable = currentExecutablePath(), !executable.isEmpty else { return nil } let expanded = (executable as NSString).expandingTildeInPath if let resolvedPath = realpath(expanded, nil) { defer { free(resolvedPath) } return URL(fileURLWithPath: String(cString: resolvedPath)).standardizedFileURL } return URL(fileURLWithPath: expanded).standardizedFileURL } private func usage() -> String { return """ cmux - control cmux via Unix socket Usage: cmux <path> Open a directory in a new workspace (launches cmux if needed) cmux [global-options] <command> [options] Handle Inputs: Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs. `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. Socket Auth: --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. Commands: welcome shortcuts feedback [--email <email> --body <text> [--image <path> ...]] themes [list|set|clear] claude-teams [claude-args...] omo [opencode-args...] codex <install-hooks|uninstall-hooks> ping version capabilities identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] list-windows current-window new-window focus-window --window <id> close-window --window <id> move-workspace-to-window --workspace <id|ref> --window <id|ref> reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>] workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] list-workspaces new-workspace [--name <title>] [--cwd <path>] [--command <text>] ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>] remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] list-panes [--workspace <id|ref>] list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] tree [--all] [--workspace <id|ref|index>] focus-pane --pane <id|ref> [--workspace <id|ref>] new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref>] [--url <url>] new-surface [--type <terminal|browser>] [--pane <id|ref>] [--workspace <id|ref>] [--url <url>] close-surface [--surface <id|ref>] [--workspace <id|ref>] move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> drag-surface-to-split --surface <id|ref> <left|right|up|down> refresh-surfaces surface-health [--workspace <id|ref>] trigger-flash [--workspace <id|ref>] [--surface <id|ref>] list-panels [--workspace <id|ref>] focus-panel --panel <id|ref> [--workspace <id|ref>] close-workspace --workspace <id|ref> select-workspace --workspace <id|ref> rename-workspace [--workspace <id|ref>] <title> rename-window [--workspace <id|ref>] <title> current-workspace read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] send [--workspace <id|ref>] [--surface <id|ref>] <text> send-key [--workspace <id|ref>] [--surface <id|ref>] <key> send-panel --panel <id|ref> [--workspace <id|ref>] <text> send-key-panel --panel <id|ref> [--workspace <id|ref>] <key> notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>] list-notifications clear-notifications claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>] set-app-focus <active|inactive|clear> simulate-app-active # tmux compatibility commands capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] wait-for [-S|--signal] <name> [--timeout <seconds>] swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] next-window | previous-window | last-window last-pane [--workspace <id|ref>] find-window [--content] [--select] <query> clear-history [--workspace <id|ref>] [--surface <id|ref>] set-hook [--list] [--unset <event>] | <event> <command> popup bind-key | unbind-key | copy-mode set-buffer [--name <name>] <text> list-buffers paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] display-message [-p|--print] <text> markdown [open] <path> (open markdown file in formatted viewer panel with live reload) browser [--surface <id|ref|index> | <surface>] <subcommand> ... browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate) browser open-split [url] browser goto|navigate <url> [--snapshot-after] browser back|forward|reload [--snapshot-after] browser url|get-url browser snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] browser eval <script> browser wait [--selector <css>] [--text <text>] [--url-contains <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>] browser click|dblclick|hover|focus|check|uncheck|scroll-into-view <selector> [--snapshot-after] browser type <selector> <text> [--snapshot-after] browser fill <selector> [text] [--snapshot-after] (empty text clears input) browser press|keydown|keyup <key> [--snapshot-after] browser select <selector> <value> [--snapshot-after] browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] browser screenshot [--out <path>] [--json] browser get <url|title|text|html|value|attr|count|box|styles> [...] browser is <visible|enabled|checked> <selector> browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ... browser frame <selector|main> browser dialog <accept|dismiss> [text] browser download [wait] [--path <path>] [--timeout-ms <ms>] browser cookies <get|set|clear> [...] browser storage <local|session> <get|set|clear> [...] browser tab <new|list|switch|close|<index>> [...] browser console <list|clear> browser errors <list|clear> browser highlight <selector> browser state <save|load> <path> browser addinitscript <script> browser addscript <script> browser addstyle <css> browser identify [--surface <id|ref|index>] help Environment: CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for ALL commands (send, list-panels, new-split, notify, etc.). CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. """ } #if DEBUG func debugUsageTextForTesting() -> String { usage() } func debugFormatDebugTerminalsPayloadForTesting( _ payload: [String: Any], idFormat: CLIIDFormat = .refs ) -> String { formatDebugTerminalsPayload(payload, idFormat: idFormat) } #endif } @main struct CMUXTermMain { static func main() { // CLI tools should ignore SIGPIPE so closed stdout pipes do not terminate the process. _ = signal(SIGPIPE, SIG_IGN) let cli = CMUXCLI(args: CommandLine.arguments) do { try cli.run() } catch { FileHandle.standardError.write(Data("Error: \(error)\n".utf8)) exit(1) } } }