From 93c167ff02dd859b603cdf7ec9275b65bb5b1346 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:22:38 -0800 Subject: [PATCH 1/4] Add Sentry breadcrumbs for claude-hook socket failures --- CLI/cmux.swift | 222 +++++++++++++++++- GhosttyTabs.xcodeproj/project.pbxproj | 15 ++ .../test_claude_hook_missing_socket_error.py | 83 +++++++ 3 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 tests/test_claude_hook_missing_socket_error.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index da0161b3..44f98629 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -1,6 +1,9 @@ import Foundation import Darwin import Security +#if canImport(Sentry) +import Sentry +#endif struct CLIError: Error, CustomStringConvertible { let message: String @@ -8,6 +11,177 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } +private final class ClaudeHookSentryTelemetry { + private let enabled: Bool + 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" +#endif + + init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { + self.enabled = command == "claude-hook" + self.subcommand = commandArgs.first?.lowercased() ?? "help" + self.socketPath = socketPath + self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] + self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] + self.surfaceId = processEnv["CMUX_SURFACE_ID"] + self.disabledByEnv = 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: "claude-hook.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 + _ = SentrySDK.capture(error: error) { scope in + scope.setLevel(.error) + scope.setTag(value: "cmux-cli", key: "component") + scope.setTag(value: subcommand, key: "claude_hook_subcommand") + scope.setContext(value: context, key: "claude_hook") + } + SentrySDK.flush(timeout: 2.0) +#endif + } + + private var shouldEmit: Bool { + enabled && !disabledByEnv + } + + private func baseContext() -> [String: Any] { + var context: [String: Any] = [ + "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.discoverTmpCmuxSockets(limit: 10) + if !tmpSockets.isEmpty { + context["tmp_cmux_sockets"] = tmpSockets + } + let taggedSockets = tmpSockets.filter { $0 != "/tmp/cmux.sock" } + if socketPath == "/tmp/cmux.sock", + (envSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true), + !taggedSockets.isEmpty { + context["possible_root_cause"] = "CMUX_SOCKET_PATH 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 discoverTmpCmuxSockets(limit: Int) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory(atPath: "/tmp") else { + return [] + } + var sockets: [String] = [] + for name in entries.sorted() { + guard name.hasPrefix("cmux"), name.hasSuffix(".sock") else { continue } + let fullPath = "/tmp/\(name)" + 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 +#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 @@ -503,6 +677,12 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) + let hookTelemetry = ClaudeHookSentryTelemetry( + command: command, + commandArgs: commandArgs, + socketPath: socketPath, + processEnv: ProcessInfo.processInfo.environment + ) if command == "version" { print(versionSummary()) @@ -518,7 +698,18 @@ struct CMUXCLI { } let client = SocketClient(path: socketPath) - try client.connect() + hookTelemetry.breadcrumb( + "socket.connect.attempt", + data: ["command": command] + ) + do { + try client.connect() + hookTelemetry.breadcrumb("socket.connect.success") + } catch { + hookTelemetry.breadcrumb("socket.connect.failure") + hookTelemetry.captureError(stage: "socket_connect", error: error) + throw error + } defer { client.close() } if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) { @@ -1103,7 +1294,15 @@ struct CMUXCLI { print(response) case "claude-hook": - try runClaudeHook(commandArgs: commandArgs, client: client) + hookTelemetry.breadcrumb("claude-hook.dispatch") + do { + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: hookTelemetry) + hookTelemetry.breadcrumb("claude-hook.completed") + } catch { + hookTelemetry.breadcrumb("claude-hook.failure") + hookTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + throw error + } case "set-status": let (icon, r1) = parseOption(commandArgs, name: "--icon") @@ -4332,7 +4531,11 @@ struct CMUXCLI { } } - private func runClaudeHook(commandArgs: [String], client: SocketClient) throws { + private func runClaudeHook( + commandArgs: [String], + client: SocketClient, + telemetry: ClaudeHookSentryTelemetry + ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) let hookWsFlag = optionValue(hookArgs, name: "--workspace") @@ -4341,11 +4544,21 @@ struct CMUXCLI { 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 + ] + ) let fallbackWorkspaceId = try resolveWorkspaceIdForClaudeHook(workspaceArg, client: client) let fallbackSurfaceId = try? resolveSurfaceId(surfaceArg, workspaceId: fallbackWorkspaceId, client: client) switch subcommand { case "session-start", "active": + telemetry.breadcrumb("claude-hook.session-start") let workspaceId = fallbackWorkspaceId let surfaceId = try resolveSurfaceIdForClaudeHook( surfaceArg, @@ -4370,6 +4583,7 @@ struct CMUXCLI { print("OK") case "stop", "idle": + telemetry.breadcrumb("claude-hook.stop") let consumedSession = try? sessionStore.consume( sessionId: parsedInput.sessionId, workspaceId: fallbackWorkspaceId, @@ -4398,6 +4612,7 @@ struct CMUXCLI { } case "notification", "notify": + telemetry.breadcrumb("claude-hook.notification") let summary = summarizeClaudeHookNotification(rawInput: rawInput) var workspaceId = fallbackWorkspaceId @@ -4442,6 +4657,7 @@ struct CMUXCLI { print(response) case "help", "--help", "-h": + telemetry.breadcrumb("claude-hook.help") print( """ cmux claude-hook [--workspace ] [--surface ] diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index dc05ce47..d8bc7236 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001250 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A5001251 /* Sentry */; }; A5001270 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = A5001271 /* PostHog */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; }; @@ -242,6 +243,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B9000024A1B2C3D4E5F60719 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -465,6 +467,9 @@ ); dependencies = ( ); + packageProductDependencies = ( + A5001251 /* Sentry */, + ); name = "cmux-cli"; productName = cmux; productReference = B9000004A1B2C3D4E5F60719 /* cmux */; @@ -801,6 +806,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; @@ -814,6 +824,11 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path", + "@executable_path/../Frameworks", + ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; PRODUCT_MODULE_NAME = cmux_cli; diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py new file mode 100644 index 00000000..96357164 --- /dev/null +++ b/tests/test_claude_hook_missing_socket_error.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +Regression test: claude-hook stop surfaces a clear socket-connect error when target socket is missing. +""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import tempfile + + +def resolve_cmux_cli() -> str: + explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI") + if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK): + return explicit + + candidates: list[str] = [] + candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux"))) + candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")) + candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)] + if candidates: + candidates.sort(key=os.path.getmtime, reverse=True) + return candidates[0] + + in_path = shutil.which("cmux") + if in_path: + return in_path + + raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.") + + +def main() -> int: + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + missing_socket = os.path.join(tempfile.gettempdir(), f"cmux-missing-{os.getpid()}.sock") + try: + if os.path.exists(missing_socket): + os.remove(missing_socket) + except OSError: + pass + + env = os.environ.copy() + env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" + env.pop("CMUX_SOCKET_PATH", None) + + proc = subprocess.run( + [cli_path, "--socket", missing_socket, "claude-hook", "stop"], + input="{}", + text=True, + capture_output=True, + env=env, + check=False, + ) + + if proc.returncode == 0: + print("FAIL: expected non-zero exit when socket is missing") + print(f"stdout={proc.stdout}") + print(f"stderr={proc.stderr}") + return 1 + + expected_prefixes = [ + f"Error: Socket not found at {missing_socket}", + f"Error: Failed to connect to socket at {missing_socket}", + ] + if not any(prefix in proc.stderr for prefix in expected_prefixes): + print("FAIL: missing expected socket error text") + print(f"expected one of: {expected_prefixes!r}") + print(f"stderr: {proc.stderr!r}") + return 1 + + print("PASS: claude-hook stop missing-socket error is explicit") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From db86d3e301177840cb422778351257781ff7aa7f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:38:05 -0800 Subject: [PATCH 2/4] Broaden CLI socket telemetry and add restart listener command --- CLI/cmux.swift | 45 ++++--- Sources/AppDelegate.swift | 25 ++++ Sources/ContentView.swift | 11 ++ .../test_claude_hook_missing_socket_error.py | 1 + tests/test_cli_socket_sentry_scope.py | 115 +++++++++++++++++ ..._command_palette_socket_restart_command.py | 118 ++++++++++++++++++ 6 files changed, 296 insertions(+), 19 deletions(-) create mode 100644 tests/test_cli_socket_sentry_scope.py create mode 100644 tests/test_command_palette_socket_restart_command.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 44f98629..b14dad7c 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -11,8 +11,8 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } -private final class ClaudeHookSentryTelemetry { - private let enabled: Bool +private final class CLISocketSentryTelemetry { + private let command: String private let subcommand: String private let socketPath: String private let envSocketPath: String? @@ -27,13 +27,15 @@ private final class ClaudeHookSentryTelemetry { #endif init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) { - self.enabled = command == "claude-hook" + self.command = command.lowercased() self.subcommand = commandArgs.first?.lowercased() ?? "help" self.socketPath = socketPath self.envSocketPath = processEnv["CMUX_SOCKET_PATH"] self.workspaceId = processEnv["CMUX_WORKSPACE_ID"] self.surfaceId = processEnv["CMUX_SURFACE_ID"] - self.disabledByEnv = processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" + self.disabledByEnv = + processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" || + processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1" } func breadcrumb(_ message: String, data: [String: Any] = [:]) { @@ -44,7 +46,7 @@ private final class ClaudeHookSentryTelemetry { for (key, value) in data { payload[key] = value } - let crumb = Breadcrumb(level: .info, category: "claude-hook.cli") + let crumb = Breadcrumb(level: .info, category: "cmux.cli") crumb.message = message crumb.data = payload SentrySDK.addBreadcrumb(crumb) @@ -62,22 +64,25 @@ private final class ClaudeHookSentryTelemetry { 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: subcommand, key: "claude_hook_subcommand") - scope.setContext(value: context, key: "claude_hook") + 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 { - enabled && !disabledByEnv + !disabledByEnv } private func baseContext() -> [String: Any] { var context: [String: Any] = [ + "command": command, "subcommand": subcommand, "requested_socket_path": socketPath, "env_socket_path": envSocketPath ?? "" @@ -677,7 +682,7 @@ struct CMUXCLI { let command = args[index] let commandArgs = Array(args[(index + 1)...]) - let hookTelemetry = ClaudeHookSentryTelemetry( + let cliTelemetry = CLISocketSentryTelemetry( command: command, commandArgs: commandArgs, socketPath: socketPath, @@ -698,16 +703,16 @@ struct CMUXCLI { } let client = SocketClient(path: socketPath) - hookTelemetry.breadcrumb( + cliTelemetry.breadcrumb( "socket.connect.attempt", data: ["command": command] ) do { try client.connect() - hookTelemetry.breadcrumb("socket.connect.success") + cliTelemetry.breadcrumb("socket.connect.success") } catch { - hookTelemetry.breadcrumb("socket.connect.failure") - hookTelemetry.captureError(stage: "socket_connect", error: error) + cliTelemetry.breadcrumb("socket.connect.failure") + cliTelemetry.captureError(stage: "socket_connect", error: error) throw error } defer { client.close() } @@ -1294,13 +1299,13 @@ struct CMUXCLI { print(response) case "claude-hook": - hookTelemetry.breadcrumb("claude-hook.dispatch") + cliTelemetry.breadcrumb("claude-hook.dispatch") do { - try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: hookTelemetry) - hookTelemetry.breadcrumb("claude-hook.completed") + try runClaudeHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry) + cliTelemetry.breadcrumb("claude-hook.completed") } catch { - hookTelemetry.breadcrumb("claude-hook.failure") - hookTelemetry.captureError(stage: "claude_hook_dispatch", error: error) + cliTelemetry.breadcrumb("claude-hook.failure") + cliTelemetry.captureError(stage: "claude_hook_dispatch", error: error) throw error } @@ -4534,7 +4539,7 @@ struct CMUXCLI { private func runClaudeHook( commandArgs: [String], client: SocketClient, - telemetry: ClaudeHookSentryTelemetry + telemetry: CLISocketSentryTelemetry ) throws { let subcommand = commandArgs.first?.lowercased() ?? "help" let hookArgs = Array(commandArgs.dropFirst()) @@ -5319,6 +5324,8 @@ struct CMUXCLI { 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 default Unix socket path (/tmp/cmux.sock). + CMUX_CLI_SENTRY_DISABLED + Set to 1 to disable CLI Sentry socket diagnostics. """ } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 337ad9f3..d9bf44b8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3229,6 +3229,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent updateController.attemptUpdate() } + @objc func restartSocketListener(_ sender: Any?) { + guard let tabManager else { + NSSound.beep() + return + } + + let raw = UserDefaults.standard.string(forKey: SocketControlSettings.appStorageKey) + ?? SocketControlSettings.defaultMode.rawValue + let userMode = SocketControlSettings.migrateMode(raw) + let mode = SocketControlSettings.effectiveMode(userMode: userMode) + guard mode != .off else { + TerminalController.shared.stop() + NSSound.beep() + return + } + + let socketPath = SocketControlSettings.socketPath() + sentryBreadcrumb("socket.listener.restart", category: "socket", data: [ + "mode": mode.rawValue, + "path": socketPath + ]) + TerminalController.shared.stop() + TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode) + } + private func setupMenuBarExtra() { let store = TerminalNotificationStore.shared menuBarExtraController = MenuBarExtraController( diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 04a6433a..29ffab15 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3530,6 +3530,14 @@ struct ContentView: View { keywords: ["attempt", "check", "update", "upgrade", "release"] ) ) + contributions.append( + CommandPaletteCommandContribution( + commandId: "palette.restartSocketListener", + title: constant("Restart CLI Listener"), + subtitle: constant("Global"), + keywords: ["restart", "socket", "listener", "cli", "cmux", "control"] + ) + ) contributions.append( CommandPaletteCommandContribution( @@ -3934,6 +3942,9 @@ struct ContentView: View { registry.register(commandId: "palette.attemptUpdate") { AppDelegate.shared?.attemptUpdate(nil) } + registry.register(commandId: "palette.restartSocketListener") { + AppDelegate.shared?.restartSocketListener(nil) + } registry.register(commandId: "palette.renameWorkspace") { beginRenameWorkspaceFlow() diff --git a/tests/test_claude_hook_missing_socket_error.py b/tests/test_claude_hook_missing_socket_error.py index 96357164..d20c7c22 100644 --- a/tests/test_claude_hook_missing_socket_error.py +++ b/tests/test_claude_hook_missing_socket_error.py @@ -47,6 +47,7 @@ def main() -> int: pass env = os.environ.copy() + env["CMUX_CLI_SENTRY_DISABLED"] = "1" env["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] = "1" env.pop("CMUX_SOCKET_PATH", None) diff --git a/tests/test_cli_socket_sentry_scope.py b/tests/test_cli_socket_sentry_scope.py new file mode 100644 index 00000000..46deeee3 --- /dev/null +++ b/tests/test_cli_socket_sentry_scope.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Regression test: CLI socket Sentry telemetry must apply to all commands.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def reject(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + "private final class CLISocketSentryTelemetry {", + "Missing CLISocketSentryTelemetry definition", + failures, + ) + require( + content, + 'processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||', + "Missing CMUX_CLI_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + 'processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"', + "Missing backwards-compatible CMUX_CLAUDE_HOOK_SENTRY_DISABLED kill switch", + failures, + ) + require( + content, + "private var shouldEmit: Bool {\n !disabledByEnv\n }", + "Telemetry scope should be command-agnostic (only disabled by env kill switch)", + failures, + ) + require( + content, + 'let crumb = Breadcrumb(level: .info, category: "cmux.cli")', + "Telemetry breadcrumb category should be cmux.cli", + failures, + ) + require( + content, + '"command": command,', + "Base telemetry context must include command name", + failures, + ) + require( + content, + "let cliTelemetry = CLISocketSentryTelemetry(", + "CLI should initialize generic socket telemetry", + failures, + ) + require( + content, + 'cliTelemetry.breadcrumb(\n "socket.connect.attempt",', + "CLI should emit socket.connect.attempt breadcrumb for commands", + failures, + ) + + reject( + content, + "self.enabled = command == \"claude-hook\"", + "Telemetry regressed to claude-hook-only scope", + failures, + ) + reject( + content, + "enabled && !disabledByEnv", + "Telemetry still depends on legacy enabled flag", + failures, + ) + + if failures: + print("FAIL: CLI socket telemetry scope regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI socket telemetry scope is command-agnostic") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_command_palette_socket_restart_command.py b/tests/test_command_palette_socket_restart_command.py new file mode 100644 index 00000000..6904c5a4 --- /dev/null +++ b/tests/test_command_palette_socket_restart_command.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Regression test for command-palette socket-listener restart command wiring.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + content_view_path = repo_root / "Sources" / "ContentView.swift" + app_delegate_path = repo_root / "Sources" / "AppDelegate.swift" + + missing_paths = [ + str(path) + for path in [content_view_path, app_delegate_path] + if not path.exists() + ] + if missing_paths: + print("Missing expected files:") + for path in missing_paths: + print(f" - {path}") + return 1 + + content_view = read_text(content_view_path) + app_delegate = read_text(app_delegate_path) + + failures: list[str] = [] + + require( + content_view, + 'commandId: "palette.restartSocketListener"', + "Missing `palette.restartSocketListener` command contribution", + failures, + ) + require( + content_view, + 'title: constant("Restart CLI Listener")', + "Missing `Restart CLI Listener` command title", + failures, + ) + require( + content_view, + 'registry.register(commandId: "palette.restartSocketListener") {', + "Missing command handler registration for `palette.restartSocketListener`", + failures, + ) + require( + content_view, + "AppDelegate.shared?.restartSocketListener(nil)", + "Socket restart command handler does not call `AppDelegate.restartSocketListener`", + failures, + ) + + require( + app_delegate, + "@objc func restartSocketListener(_ sender: Any?) {", + "Missing `AppDelegate.restartSocketListener` action", + failures, + ) + require( + app_delegate, + "let mode = SocketControlSettings.effectiveMode(userMode: userMode)", + "`restartSocketListener` no longer uses effective socket control mode", + failures, + ) + require( + app_delegate, + "let socketPath = SocketControlSettings.socketPath()", + "`restartSocketListener` no longer uses configured socket path", + failures, + ) + require( + app_delegate, + "TerminalController.shared.stop()", + "`restartSocketListener` no longer stops current listener before restart", + failures, + ) + require( + app_delegate, + "TerminalController.shared.start(tabManager: tabManager, socketPath: socketPath, accessMode: mode)", + "`restartSocketListener` no longer starts listener with current settings", + failures, + ) + + if failures: + print("FAIL: command-palette socket restart command regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: command-palette socket restart command wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 915c01f9d0233b37e2760a7679806aaad5751b5a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:47:01 -0800 Subject: [PATCH 3/4] Add ../../Frameworks rpath for bundled cmux cli --- GhosttyTabs.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d8bc7236..c3f2b4d9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -810,6 +810,7 @@ "$(inherited)", "@executable_path", "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; @@ -828,6 +829,7 @@ "$(inherited)", "@executable_path", "@executable_path/../Frameworks", + "@executable_path/../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_NAME = cmux; From f7ef0707c289aabd48366e091ef849490f80be8d Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:51:43 -0800 Subject: [PATCH 4/4] Include commit metadata in cmux --version output --- CLI/cmux.swift | 110 ++++++++++++++++++---- tests/test_cli_version_commit_metadata.py | 85 +++++++++++++++++ 2 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 tests/test_cli_version_commit_metadata.py diff --git a/CLI/cmux.swift b/CLI/cmux.swift index b14dad7c..c1c98599 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4988,39 +4988,63 @@ struct CMUXCLI { 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"] { - return "cmux \(version) (\(build))" + 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" } - if let version = info["CFBundleShortVersionString"] { - return "cmux \(version)" - } - if let build = info["CFBundleVersion"] { - return "cmux build \(build)" - } - return "cmux version unknown" + guard let commit else { return baseSummary } + return "\(baseSummary) [\(commit)]" } private func resolvedVersionInfo() -> [String: String] { + var info: [String: String] = [:] if let main = versionInfo(from: Bundle.main.infoDictionary) { - return main + info.merge(main, uniquingKeysWith: { current, _ in current }) } - 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 + 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 + } } - return parsed } - if let fromProject = versionInfoFromProjectFile() { - return fromProject + let needsProjectFallback = + info["CFBundleShortVersionString"] == nil || + info["CFBundleVersion"] == nil || + info["CMUXCommit"] == nil + if needsProjectFallback, let fromProject = versionInfoFromProjectFile() { + info.merge(fromProject, uniquingKeysWith: { current, _ in current }) } - return [:] + 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]? { @@ -5039,6 +5063,10 @@ struct CMUXCLI { info["CFBundleVersion"] = trimmed } } + if let commit = dictionary["CMUXCommit"] as? String, + let normalizedCommit = normalizedCommitHash(commit) { + info["CMUXCommit"] = normalizedCommit + } return info.isEmpty ? nil : info } @@ -5064,6 +5092,9 @@ struct CMUXCLI { 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 } @@ -5100,6 +5131,45 @@ struct CMUXCLI { 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)) + } + private func candidateInfoPlistURLs() -> [URL] { guard let executable = currentExecutablePath(), !executable.isEmpty else { return [] diff --git a/tests/test_cli_version_commit_metadata.py b/tests/test_cli_version_commit_metadata.py new file mode 100644 index 00000000..3029fe0d --- /dev/null +++ b/tests/test_cli_version_commit_metadata.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Regression test: CLI version output wiring keeps commit metadata support.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def get_repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path.cwd() + + +def require(content: str, needle: str, message: str, failures: list[str]) -> None: + if needle not in content: + failures.append(message) + + +def main() -> int: + repo_root = get_repo_root() + cli_path = repo_root / "CLI" / "cmux.swift" + if not cli_path.exists(): + print(f"FAIL: missing expected file: {cli_path}") + return 1 + + content = cli_path.read_text(encoding="utf-8") + failures: list[str] = [] + + require( + content, + 'let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }', + "versionSummary no longer reads CMUXCommit metadata", + failures, + ) + require( + content, + 'return "\\(baseSummary) [\\(commit)]"', + "versionSummary no longer appends commit metadata", + failures, + ) + require( + content, + 'if let commit = dictionary["CMUXCommit"] as? String,', + "Info.plist parsing no longer reads CMUXCommit", + failures, + ) + require( + content, + "if let commit = gitCommitHash(at: current) {", + "Project fallback no longer probes git commit hash", + failures, + ) + require( + content, + '["git", "-C", directory.path, "rev-parse", "--short=9", "HEAD"]', + "Git commit probe command changed unexpectedly", + failures, + ) + require( + content, + 'normalizedCommitHash(ProcessInfo.processInfo.environment["CMUX_COMMIT"])', + "Environment commit fallback (CMUX_COMMIT) is missing", + failures, + ) + + if failures: + print("FAIL: CLI version commit metadata regression(s) detected") + for failure in failures: + print(f"- {failure}") + return 1 + + print("PASS: CLI version commit metadata wiring is intact") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())