Merge pull request #460 from manaflow-ai/task-stop-hook-socket-breadcrumbs
Add CLI socket diagnostics and restart-listener command
This commit is contained in:
commit
65f5b9be6d
8 changed files with 771 additions and 23 deletions
339
CLI/cmux.swift
339
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,182 @@ struct CLIError: Error, CustomStringConvertible {
|
|||
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"
|
||||
#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"]
|
||||
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 ?? "<unset>"
|
||||
]
|
||||
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 +682,12 @@ struct CMUXCLI {
|
|||
|
||||
let command = args[index]
|
||||
let commandArgs = Array(args[(index + 1)...])
|
||||
let cliTelemetry = CLISocketSentryTelemetry(
|
||||
command: command,
|
||||
commandArgs: commandArgs,
|
||||
socketPath: socketPath,
|
||||
processEnv: ProcessInfo.processInfo.environment
|
||||
)
|
||||
|
||||
if command == "version" {
|
||||
print(versionSummary())
|
||||
|
|
@ -518,7 +703,18 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
let client = SocketClient(path: socketPath)
|
||||
try client.connect()
|
||||
cliTelemetry.breadcrumb(
|
||||
"socket.connect.attempt",
|
||||
data: ["command": command]
|
||||
)
|
||||
do {
|
||||
try client.connect()
|
||||
cliTelemetry.breadcrumb("socket.connect.success")
|
||||
} catch {
|
||||
cliTelemetry.breadcrumb("socket.connect.failure")
|
||||
cliTelemetry.captureError(stage: "socket_connect", error: error)
|
||||
throw error
|
||||
}
|
||||
defer { client.close() }
|
||||
|
||||
if let socketPassword = SocketPasswordResolver.resolve(explicit: socketPasswordArg) {
|
||||
|
|
@ -1103,7 +1299,15 @@ struct CMUXCLI {
|
|||
print(response)
|
||||
|
||||
case "claude-hook":
|
||||
try runClaudeHook(commandArgs: commandArgs, client: client)
|
||||
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 "set-status":
|
||||
let (icon, r1) = parseOption(commandArgs, name: "--icon")
|
||||
|
|
@ -4332,7 +4536,11 @@ struct CMUXCLI {
|
|||
}
|
||||
}
|
||||
|
||||
private func runClaudeHook(commandArgs: [String], client: SocketClient) throws {
|
||||
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")
|
||||
|
|
@ -4341,11 +4549,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 +4588,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 +4617,7 @@ struct CMUXCLI {
|
|||
}
|
||||
|
||||
case "notification", "notify":
|
||||
telemetry.breadcrumb("claude-hook.notification")
|
||||
let summary = summarizeClaudeHookNotification(rawInput: rawInput)
|
||||
|
||||
var workspaceId = fallbackWorkspaceId
|
||||
|
|
@ -4442,6 +4662,7 @@ struct CMUXCLI {
|
|||
print(response)
|
||||
|
||||
case "help", "--help", "-h":
|
||||
telemetry.breadcrumb("claude-hook.help")
|
||||
print(
|
||||
"""
|
||||
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
|
||||
|
|
@ -4767,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]? {
|
||||
|
|
@ -4818,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
|
||||
}
|
||||
|
||||
|
|
@ -4843,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
|
||||
}
|
||||
|
|
@ -4879,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 []
|
||||
|
|
@ -5103,6 +5394,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.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
@ -814,6 +825,12 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||
PRODUCT_NAME = cmux;
|
||||
PRODUCT_MODULE_NAME = cmux_cli;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
84
tests/test_claude_hook_missing_socket_error.py
Normal file
84
tests/test_claude_hook_missing_socket_error.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#!/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_CLI_SENTRY_DISABLED"] = "1"
|
||||
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())
|
||||
115
tests/test_cli_socket_sentry_scope.py
Normal file
115
tests/test_cli_socket_sentry_scope.py
Normal file
|
|
@ -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())
|
||||
85
tests/test_cli_version_commit_metadata.py
Normal file
85
tests/test_cli_version_commit_metadata.py
Normal file
|
|
@ -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())
|
||||
118
tests/test_command_palette_socket_restart_command.py
Normal file
118
tests/test_command_palette_socket_restart_command.py
Normal file
|
|
@ -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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue