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] 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())