diff --git a/exec/load/xtrnmenulib.js b/exec/load/xtrnmenulib.js
new file mode 100644
index 0000000000000000000000000000000000000000..e7cff9fab33e45554f2dd4c01a553cac101bcc5d
--- /dev/null
+++ b/exec/load/xtrnmenulib.js
@@ -0,0 +1,410 @@
+/**
+ * Custom External Program Menu Library for Custom External Program Menus
+ * by Michael Long mlong  innerrealmbbs.us
+ * 
+ * This provides common functionality for retrieving menus used by 
+ * the loadable module xtrnmenu.js and by the web interface
+ * 099-xtrnmenu-games.xjs
+ */
+
+"use strict";
+
+load("sbbsdefs.js", "K_NONE");
+
+/* text.dat entries */
+require("text.js", "XtrnProgLstFmt");
+
+function ExternalMenus() {
+	this.options = {};
+	this.xtrn_custommenu_options = {};
+	this.menuconfig = {};
+	
+	this.getOptions();
+	this.getMenuConfig();
+}
+
+ExternalMenus.prototype.getMenuConfig = function() {
+	var config_file = new File(system.ctrl_dir + "xtrnmenu.cfg");
+	var config_src;
+	this.menuconfig = undefined;
+	if (config_file.open('r+')) {
+		config_src = config_file.read();
+		config_file.close();
+	}
+	
+	if (typeof config_src !== "undefined") {
+		this.menuconfig = JSON.parse(config_src.toString());
+	}
+}
+
+ExternalMenus.prototype.getOptions = function(menutype, menuid) {
+	if (typeof menutype === "undefined") {
+		menutype = 'custommenu';
+	}
+	
+	// Get xtrn_sec options from modopts.ini [xtrn_sec]
+	if ((this.options = load({}, "modopts.js", "xtrn_sec")) == null) {
+		this.options = { multicolumn: true, sort: false };
+	}
+
+	// Get xtrn_custommenu options from modopts.ini [xtrn_custommenu]
+	if ((this.xtrn_custommenu_options = load({}, "modopts.js", "xtrnmenu")) == null) {
+		this.xtrn_custommenu_options = { };
+	}
+
+	// in all cases, we start with the xtrn_sec options as the base and set the defaults
+	if (this.options.multicolumn === undefined)
+		this.options.multicolumn = true;
+
+	if (this.options.multicolumn_separator === undefined)
+		this.options.multicolumn_separator = " ";
+
+	if (this.options.multicolumn_fmt === undefined)
+		this.options.multicolumn_fmt = system.text(XtrnProgLstFmt);
+	
+	if (this.options.singlecolumn_fmt === undefined)
+		this.options.singlecolumn_fmt = "\x01h\x01c%3u \xb3 \x01n\x01c%s\x01h ";
+
+	if (this.options.singlecolumn_margin == undefined)
+		this.options.singlecolumn_margin = 7;
+
+	if (typeof bbs !== "undefined") {
+		if (this.options.singlecolumn_height == undefined)
+			this.options.singlecolumn_height = console.screen_rows - this.options.singlecolumn_margin;
+
+		// override and turn off multicolumn if terminal width is less than 80
+		if (console.screen_columns < 80)
+			options.multicolumn = false;
+	}
+	
+	if (this.options.restricted_user_msg === undefined)
+		this.options.restricted_user_msg = system.text(R_ExternalPrograms);
+	
+	if (this.options.no_programs_msg === undefined) 
+		this.options.no_programs_msg =  system.text(NoXtrnPrograms);
+	
+	if (this.options.header_fmt === undefined)
+		this.options.header_fmt = system.text(XtrnProgLstHdr);
+
+	if (this.options.titles === undefined)
+		this.options.titles = system.text(XtrnProgLstTitles);
+
+	if (this.options.underline === undefined)
+		this.options.underline = system.text(XtrnProgLstUnderline);
+
+	if (this.options.which === undefined) 
+		this.options.which = system.text(WhichXtrnProg);
+
+	if (this.options.clear_screen === undefined)
+		this.options.clear_screen = true;
+
+	if (this.options.section_fmt === undefined)
+		this.options.section_fmt = "\x01y\x01g%3d:\x01n\x01g %s"
+
+	if (this.options.section_header_fmt === undefined)
+		this.options.section_header_fmt = "\x01-\x01gSelect \x01hExternal Program Section\x01-\x01g:"
+
+	if (this.options.section_which === undefined)
+		this.options.section_which = "\r\n\x01-\x01gWhich, \x01w\x01h~Q\x01n\x01guit or [1]: \x01h"
+
+	// if its a custom menu, then override with custommenu_options
+	if (menutype == 'custommenu') {
+		if (typeof this.xtrn_custommenu_options.multicolumn_fmt !== "undefined") {
+			this.options.multicolumn_fmt = this.xtrn_custommenu_options.multicolumn_fmt;
+		} else {
+			// cannot default to xtrn_sec multicolumn_fmt due to use of %u instead of %s
+			this.options.multicolumn_fmt = "\x01h\x01c%3s \xb3 \x01n\x01c%-32.32s\x01h ";
+		}
+
+		if (typeof this.xtrn_custommenu_options.singlecolumn_fmt !== "undefined") {
+			this.options.singlecolumn_fmt = this.xtrn_custommenu_options.singlecolumn_fmt;
+		} else {
+			// cannot default to xtrn_sec multicolumn_fmt due to use of %u instead of %s
+			this.options.singlecolumn_fmt = "\x01h\x01c%3s \xb3 \x01n\x01c%s\x01h ";
+		}
+
+		this.options.header_fmt = (typeof this.xtrn_custommenu_options.header_fmt !== "undefined") 
+			? this.xtrn_custommenu_options.header_fmt : this.options.header_fmt;
+
+		this.options.titles = (typeof this.xtrn_custommenu_options.titles !== "undefined")
+			? this.xtrn_custommenu_options.titles : this.options.titles;
+
+		this.options.which = (typeof this.xtrn_custommenu_options.which !== "undefined")
+			? this.xtrn_custommenu_options.which : this.options.which;
+
+		this.options.underline = (typeof this.xtrn_custommenu_options.underline !== "undefined")
+			? this.xtrn_custommenu_options.underline : this.options.underline;
+
+		this.options.multicolumn_separator = (typeof this.xtrn_custommenu_options.multicolumn_separator !== "undefined")
+			? this.xtrn_custommenu_options.multicolumn_separator : this.options.multicolumn_separator;
+
+		this.options.multicolumn = (typeof this.xtrn_custommenu_options.multicolumn !== "undefined")
+			? this.xtrn_custommenu_options.multicolumn : this.options.multicolumn;
+
+		this.options.sort = (typeof this.xtrn_custommenu_options.sort !== "undefined")
+			? this.xtrn_custommenu_options.sort : this.options.sort;
+
+		this.options.clear_screen = (typeof this.xtrn_custommenu_options.clear_screen !== "undefined")
+			? this.xtrn_custommenu_options.clear_screen : this.options.clear_screen;
+
+		this.options.singlecolumn_margin = (typeof this.xtrn_custommenu_options.singlecolumn_margin !== "undefined")
+			? this.xtrn_custommenu_options.singlecolumn_margin : this.options.singlecolumn_margin;
+
+		if (typeof bbs !== "undefined") {
+			this.options.singlecolumn_height = (typeof this.xtrn_custommenu_options.singlecolumn_height !== "undefined")
+				? this.xtrn_custommenu_options.singlecolumn_height : this.options.singlecolumn_height;
+		}
+
+		// no need to override restricted_user_msg or no_programs_msg - these
+		// will be the same for both types of menus
+		
+		// Allow overriding on a per-menu basis
+		var menuoptions = load({}, "modopts.js", "xtrnmenu:" + menuid);
+		if ((typeof menuid !== "undefined") && (menuoptions != null)) {
+			for (var m in menuoptions) {
+				this.options[m] = menuoptions[m];
+			}
+		}
+	}
+	
+	// these options only apply to terminals/consoles
+	
+	// the intention is to obtain all the mod_opts options for xtrn_sec, and
+	// override if a custom menu global setting is set
+
+	//// The following are used for the enhanced custom menu functionality
+	if (this.options.custom_menu_not_found_msg === undefined) {
+		this.options.custom_menu_not_found_msg = "Menu %MENUID% not found";
+	}
+	
+	if (this.options.custom_menu_program_not_found_msg === undefined) {
+		this.options.custom_menu_program_not_found_msg = "Program %PROGRAMID% not found";
+	}
+	
+	return this.options;
+}
+
+// return a custom menu object
+ExternalMenus.prototype.getMenu = function(menuid) {
+	// grab the specified menu, or get the main menu
+	if ((typeof menuid === "undefined") || !menuid) {
+		menuid = "main";
+	} else {
+		menuid = menuid.toLowerCase();
+	}
+
+	var menu;
+	
+	if ((typeof this.menuconfig !== "undefined") && (typeof this.menuconfig.menus !== "undefined")) {
+		this.menuconfig.menus.some(function (indmenu) {
+			if (indmenu.id.toLowerCase() == menuid) {
+				menu = indmenu;
+			}
+		});
+	}
+	
+	if (!menu && (menuid == "main")) {
+		// no custom menus defined, make one to mimic old behavior
+		
+		var menuitems = [];
+
+		var i;
+		xtrn_area.sec_list.forEach(function (sec) {
+			if (sec.can_access) {
+				menuitems.push({
+					"input": i,
+					"target": sec.code,
+					"type": "xtrnmenu",
+					"title": sec.name,
+					"access_string": sec.ars
+				});
+				i++;
+			}
+		});
+		
+		menu = {
+			"id": "main",
+			"title": "Main Menu",
+			"items": menuitems
+		};
+	}
+	return menu;
+}
+
+// return a section menu object (stock synchronet external door section)
+ExternalMenus.prototype.getSectionMenu = function(menuid) {
+
+	// grab the specified menu, or get the main menu
+	if ((typeof menuid === "undefined") || !menuid) {
+		return false;
+	}
+	
+	var menuitems = [];
+	var menu, title;
+	
+	xtrn_area.sec_list.some(function (sec) {
+		
+		if (sec.code.toLowerCase() == menuid.toLowerCase()) {
+			title = sec.name;
+
+			if (!sec.can_access || sec.prog_list.length < 1) {
+				return false;
+			}
+			
+			var i = 1;
+			sec.prog_list.some(function (prog) {
+				menuitems.push({
+					'input' : i,
+					'target': prog.code,
+					'title': prog.name,
+					'type': 'xtrnprog',
+					'access_string': prog.ars,
+					'cost': prog.cost
+				});
+				i++;
+			});
+			
+			if (menuitems.length > 0) {
+				menu = {
+					'id': menuid,
+					'title': title,
+					'items': menuitems,
+				};
+			}
+			return;
+		}
+	});
+
+	return menu;
+}
+
+// Sort the menu items according to options
+ExternalMenus.prototype.getSortedItems = function(menuobj) {
+	var sort_type;
+	
+	if ((typeof menuobj.sort_type !== "undefined") && menuobj.sort_type) {
+		sort_type = menuobj.sort_type; // "name" or "key"
+	} else {
+		sort_type = this.options.sort; // bool
+	}
+	
+	// first, build a new menu with only options they have access to
+	var menuitemsfiltered = [];
+	for (i in menuobj.items) {
+		switch (menuobj.items[i].type) {
+			case 'xtrnmenu':
+				for (j in xtrn_area.sec_list) {
+					if (xtrn_area.sec_list[j].code.toUpperCase() == menuobj.items[i].target.toUpperCase()) {
+						if (xtrn_area.sec_list[j].can_access) {
+							menuitemsfiltered.push(menuobj.items[i]);
+						}
+					}
+				}
+				break;
+			
+			case 'xtrnprog':
+				for (j in xtrn_area.sec_list) {
+					for (var k in xtrn_area.sec_list[j].prog_list) {
+						if (xtrn_area.sec_list[j].prog_list[k].code.toUpperCase() == menuobj.items[i].target.toUpperCase()) {
+							if (xtrn_area.sec_list[j].prog_list[k].can_access) {
+								menuitemsfiltered.push(menuobj.items[i]);
+							}
+						}
+					}
+				}
+				break;
+			
+			case 'custommenu':
+			default:
+				if ((typeof menuobj.items[i].access_string === "undefined") || !menuobj.items[i].access_string) {
+					// no access string defined, everyone gets access
+					menuitemsfiltered.push(menuobj.items[i]);
+				} else {
+					if (user.compare_ars(menuobj.items[i].access_string)) {
+						// they have access
+						menuitemsfiltered.push(menuobj.items[i]);
+					}
+				}
+				break;
+		}
+	}
+	
+	// if no custom input keys are specified for an input, then assign a numeric input
+	// this would mimic the built-in external section menu functionality
+	// but this is only assigned on sort_type key because if the sort type is for
+	// titles, we need to sort by title first before assigning the sequential numbers
+	var sortind = 0;
+	// make sure we only use the next available number for automatic assignment
+	for (i in menuitemsfiltered) {
+		if (!isNaN(menuitemsfiltered[i].input)) {
+			if (menuitemsfiltered[i].input > sortind) {
+				sortind = menuitemsfiltered[i].input;
+			}
+		}	
+	}
+	sortind++;
+	
+	if (sort_type == "key") {
+		for (i in menuitemsfiltered) {
+			if (!menuitemsfiltered[i].input) {
+				menuitemsfiltered[i].input = sortind;
+				sortind++;
+			}
+		}
+	}
+	
+	if (sort_type) {
+		switch (sort_type) {
+			case "key":
+				menuitemsfiltered.sort(this.sort_by_input);
+				break;
+			default:
+			case "title":
+			case "name":
+				menuitemsfiltered.sort(this.sort_by_title);
+				break;
+		}
+	}
+	
+	// if this is a sort by title and the key is empty, it will be assigned the next available number
+	// this is to support auto-generated inputs like is done on the built-in section menus
+	// however, to keep the numeric keys in order, the numbers could not be pre-assigned like it done
+	// on the sort_type key
+	for (i in menuitemsfiltered) {
+		if ((sort_type !== "key") && !menuitemsfiltered[i].input) {
+			menuitemsfiltered[i].input = sortind;
+			sortind++;
+		}
+	}
+	
+	return menuitemsfiltered;
+}
+
+// Original sort function used by external_program_menu and external_section_menu
+ExternalMenus.prototype.sort_by_name = function(a, b) {
+	if(a.name.toLowerCase()>b.name.toLowerCase()) return 1;
+	if(a.name.toLowerCase()<b.name.toLowerCase()) return -1;
+	return 0;
+}
+
+// Sort by title - used by external_section_menu_custom
+ExternalMenus.prototype.sort_by_title = function(a, b) {
+	if (a.title.toLowerCase() < b.title.toLowerCase()) {
+		return -1;
+	}
+	if (a.title.toLowerCase() > b.title.toLowerCase()) {
+		return 1;
+	}
+	return 0;
+}
+
+// Sort by input key - used by external_section_menu_custom
+ExternalMenus.prototype.sort_by_input = function(a, b) {
+	if (a.input.toString().toLowerCase() < b.input.toString().toLowerCase()) {
+		return -1;
+	}
+	if (a.input.toString().toLowerCase() > b.input.toString().toLowerCase()) {
+		return 1;
+	}
+	return 0;
+}
diff --git a/exec/xtrnmenu.js b/exec/xtrnmenu.js
new file mode 100644
index 0000000000000000000000000000000000000000..7a32868f43ebfac10e18da3372c0a19a9279786f
--- /dev/null
+++ b/exec/xtrnmenu.js
@@ -0,0 +1,266 @@
+/**
+ * Xtrn Menu Mod
+ * Custom External Program Menus
+ * by Michael Long mlong  innerrealmbbs.us
+ *
+ * This is the loadable module that displays the custom external menus
+ * in terminal server (telnet/rlogin/ssh)
+ * 
+ * To jump to a specific menu, pass the ID as an argument
+ * 
+ * To set options, add to modopts.ini [xtrnmenu]
+ * 
+ * See instructions at http://wiki.synchro.net/module:xtrnmenu
+ */
+
+"use strict";
+
+require("sbbsdefs.js", "K_NONE");
+
+load("xtrnmenulib.js");
+
+var options, xsec = -1;
+
+//// Main
+var ExternalMenus = new ExternalMenus();
+const menuconfig = ExternalMenus.menuconfig;
+
+{
+	var i,j;
+	for(i in argv) {
+		for(j in xtrn_area.sec_list) {
+			if(argv[i].toLowerCase()==xtrn_area.sec_list[j].code)
+				xsec=j;
+		}
+	}
+}
+if (xsec > -1) {
+	// if its the id of a standard section menu, send it to the 
+	// stock menu
+	js.exec("xtrn_sec.js", {}, xsec);
+} else if (typeof argv[0] !== "undefined") {
+	// if its not a section menu, assume it is a custom menu
+	external_section_menu_custom(argv[0]);
+} else {
+	// main custom menu
+	external_section_menu_custom();
+}
+
+
+// Renders the top-level external menu
+function external_section_menu_custom(menuid)
+{
+	var i, menucheck, menuobj, item_multicolumn_fmt, item_singlecolumn_fmt,
+        cost, multicolumn, menuitemsfiltered = [];
+	var validkeys = []; // valid chars on menu selection
+	var keymax = 0; // max integer allowed on menu selection
+
+	var options = ExternalMenus.getOptions('custommenu', menuid);
+	
+	menuobj = ExternalMenus.getMenu(menuid);
+
+	// Allow overriding auto-format on a per-menu basis
+	var multicolumn_fmt = options.multicolumn_fmt;
+	var singlecolumn_fmt = options.singlecolumn_fmt;
+
+	while (bbs.online) {
+		console.aborted = false;
+
+		if (typeof menuobj === "undefined") {
+			doerror(options.custom_menu_not_found_msg.replace('%MENUID%', menuid));
+			break;
+		}
+
+		if (options.clear_screen) {
+			console.clear(LIGHTGRAY);
+		}
+
+		if (user.security.restrictions&UFLAG_X) {
+			write(options.restricted_user_msg);
+			break;
+		}
+
+		if (!xtrn_area.sec_list.length) {
+			write(options.no_programs_msg);
+			break;
+		}
+
+		var keyin;
+
+		system.node_list[bbs.node_num-1].aux = 0; /* aux is 0, only if at menu */
+		bbs.node_action = NODE_XTRN;
+		bbs.node_sync();
+
+        menuitemsfiltered = ExternalMenus.getSortedItems(menuobj);
+
+		if (!bbs.menu("xtrnmenu_head_" + menuid, P_NOERROR) && !bbs.menu("xtrnmenu_head", P_NOERROR)) {
+			bbs.menu("xtrn_head", P_NOERROR);
+	    }
+
+		// if file exists text/menu/xtrnmenu_(menuid).[rip|ans|mon|msg|asc], 
+		// then display that, otherwise dynamiic
+	    if (!bbs.menu("xtrnmenu_" + menuid, P_NOERROR)) {
+
+			// if no custom menu file in text/menu, create a dynamic one
+			multicolumn = options.multicolumn && menuitemsfiltered.length > options.singlecolumn_height;
+			
+			printf(options.header_fmt, menuobj.title);
+			if(options.titles.trimRight() != '')
+				write(options.titles);
+			if(multicolumn) {
+				write(options.multicolumn_separator);
+				if (options.titles.trimRight() != '')
+					write(options.titles);
+			}
+			if(options.underline.trimRight() != '') {
+				console.crlf();
+				write(options.underline);
+			}
+			if(multicolumn) {
+				write(options.multicolumn_separator);
+				if (options.underline.trimRight() != '')
+					write(options.underline);
+			}
+			console.crlf();
+			
+			// n is the number of items for the 1st column
+			var n;
+			if (multicolumn) {
+				n = Math.floor(menuitemsfiltered.length / 2) + (menuitemsfiltered.length & 1);
+			} else {
+				n = menuitemsfiltered.length;
+			}
+			
+            // j is the index for each menu item on 2nd column
+			var j = n; // start j at the first item for 2nd column
+			for (i = 0; i < n && !console.aborted; i++) {
+			    cost = "";
+			    if (menuitemsfiltered[i].type == "xtrnprog") {
+			        // if its an external program, get the cost
+                    cost = xtrn_area.prog[menuitemsfiltered[i].target.toLowerCase()].cost;
+                }
+
+                console.add_hotspot(menuitemsfiltered[i].input.toString());
+			    
+				validkeys.push(menuitemsfiltered[i].input.toString());
+				var intCheck = Number(menuitemsfiltered[i].input);
+				if (!intCheck.isNaN) {
+					if (intCheck > keymax) {
+						keymax = menuitemsfiltered[i].input;
+					}
+				}
+
+				// allow overriding format on a per-item basis
+                // great for featuring a specific game
+				var checkkey = menuitemsfiltered[i].target + '-multicolumn_fmt';
+				checkkey = checkkey.toLowerCase();
+				item_multicolumn_fmt = (typeof options[checkkey] !== "undefined") ?
+					options[checkkey] : options.multicolumn_fmt;
+
+				checkkey = menuitemsfiltered[i].target + '-singlecolumn_fmt'
+				checkkey = checkkey.toLowerCase();
+				item_singlecolumn_fmt = (typeof options[checkkey] !== "undefined") ? 
+					options[checkkey] : options.singlecolumn_fmt;
+
+				printf(multicolumn ? item_multicolumn_fmt : item_singlecolumn_fmt,
+                    menuitemsfiltered[i].input.toString().toUpperCase(),
+                    menuitemsfiltered[i].title,
+                    cost
+				);
+
+				if (multicolumn) {
+					if ((typeof(menuitemsfiltered[j]) !== "undefined")) {
+						validkeys.push(menuitemsfiltered[j].input.toString());
+
+						var intCheck = Number(menuitemsfiltered[j].input);
+						if (!intCheck.isNaN) {
+							if (intCheck > keymax) {
+								keymax = menuitemsfiltered[j].input;
+							}
+						}
+
+						// allow overriding format on a per-item basis
+						// great for featuring a specific game
+						var checkkey = menuitemsfiltered[j].target + '-multicolumn_fmt';
+						checkkey = checkkey.toLowerCase();
+						item_multicolumn_fmt = (typeof options[checkkey] !== "undefined") ?
+							options[checkkey] : options.multicolumn_fmt;
+
+						checkkey = menuitemsfiltered[j].target + '-singlecolumn_fmt'
+						checkkey = checkkey.toLowerCase();
+
+                        write(options.multicolumn_separator);
+						console.add_hotspot(menuitemsfiltered[j].input.toString());
+						printf(item_multicolumn_fmt,
+                            menuitemsfiltered[j].input.toString().toUpperCase(),
+							menuitemsfiltered[j].title,
+                            cost
+						);
+					} else {
+						write(options.multicolumn_separator);
+					}
+					j++;
+				}
+				console.crlf();
+			}
+
+			if (!bbs.menu("xtrnmenu_tail_" + menuid, P_NOERROR) && !bbs.menu("xtrnmenu_tail", P_NOERROR)) {
+				bbs.menu("xtrn_tail", P_NOERROR);
+			}
+
+			bbs.node_sync();
+			console.mnemonics(options.which);
+		}
+
+		validkeys.push('q');
+		keyin = console.getkeys(validkeys, keymax, K_NONE);
+		keyin = keyin.toString().toLowerCase();
+
+		if (keyin) {
+			// q for quit
+			if (keyin == "q") {
+				console.clear();
+				break; 
+			}
+			
+			menuitemsfiltered.some(function (menuitemfiltered) {
+				var menutarget = menuitemfiltered.target.toLowerCase();
+				var menuinput = menuitemfiltered.input.toString().toLowerCase();
+
+				if (menuinput == keyin) {
+					switch (menuitemfiltered.type) {
+						// custom menu, defined in xtrnmenu.json
+						case 'custommenu':
+							external_section_menu_custom(menutarget);
+							return true;
+						// external program section
+						case 'xtrnmenu':
+							js.exec("xtrn_sec.js", {}, menutarget);
+							//js.exec(js.exec_dir + 'xtrn_sec.js ' + menutarget)
+							return true;
+						// external program
+						case 'xtrnprog':
+							// run the external program
+							if (typeof xtrn_area.prog[menutarget] !== "undefined") {
+								bbs.exec_xtrn(menutarget);
+								return true;
+							} else {
+								doerror(options.custom_menu_program_not_found_msg.replace('%PROGRAMID%', menutarget));
+							}
+							break;
+					} //switch
+				} // if menu item matched keyin
+			}); // foreach menu item
+		} // if keyin
+	} // main bbs.online loop
+}
+
+
+// Display error message to console and save to log
+function doerror(msg)
+{
+	console.crlf();
+	log(LOG_ERR, msg);
+	console.putmsg('\x01+\x01h\x01r' +msg + ". The sysop has been notified." + '\x01-\r\n');
+}
+
diff --git a/exec/xtrnmenucfg.js b/exec/xtrnmenucfg.js
new file mode 100644
index 0000000000000000000000000000000000000000..9329b925fd5ff107615e799c6f74fad5a19a3b13
--- /dev/null
+++ b/exec/xtrnmenucfg.js
@@ -0,0 +1,826 @@
+"use strict"
+
+/**
+ * Menu editor for Custom External Program Menus
+ * by Michael Long mlong  innerrealmbbs.us
+ *
+ * This edits the file xtrnmenu.cfg
+ */
+
+load("sbbsdefs.js");
+load("uifcdefs.js");
+
+var log = function(msg) {
+    var f = new File("uifc.log");
+    f.open("a");
+    f.writeln(system.timestr() + ": " + msg);
+    f.close();
+}
+
+/**
+ * Write the menus out to a file
+ */
+var saveMenus = function() {
+
+    var filename = system.ctrl_dir + "xtrnmenu.cfg";
+
+    file_backup(filename, 5);
+
+    var config_file = new File(filename);
+    if (!config_file.open('w+')) {
+        uifc.msg("ERROR: Could not write to " + filename);
+        return;
+    }
+    config_file.write(JSON.stringify(menuconfig, null, '    '));
+    config_file.close();
+
+    uifc.msg("Config Saved");
+}
+
+/**
+ * Edit a custom menu
+ * @param menuid
+ */
+var editMenu = function(menuid) {
+    var menuindex, menu, menuindex, selection, selection2, editproperty;
+    var last = 0;
+    var selections = [], displayoptions = [], displayoptionids = [];
+    var menusize = menuconfig.menus.length;
+
+    // new menu but no code given, make one
+    if ((typeof menuid === "undefined") || (!menuid)) {
+        menuid = time();
+    }
+
+    // look for existing menu
+    for (var i in menuconfig.menus) {
+        if (menuconfig.menus[i].id == menuid) {
+            menu = menuconfig.menus[i];
+            menuindex = i;
+        }
+    }
+
+    if (typeof menu  === "undefined") {
+        menuindex = menusize;
+        menuconfig.menus[menuindex] = {
+            'id': menuid,
+            'title': "New Generated Menu " + menuid,
+            "sort_type": "title",
+            'items': []
+        };
+        menu = menuconfig.menus[menuindex];
+    }
+
+    while(1) {
+        uifc.help_text = word_wrap("This screen allows you to edit the configuration options for the custom menu.\r\n\r\nMost options default or are set in modopts.ini, but here you can define them on a per-menu basis.\r\n\r\nClick Edit Items to edit the individual entries (programs, menus, etc.)");
+
+        selections = [];
+        for (var j in menu.items) {
+            selections.push(menu.items.index);
+        }
+
+        displayoptions = [];
+        displayoptionids = [];
+
+        // setup display menu
+        displayoptions.push(format("%23s: %s", "id",
+            ("id" in menu ? menu.id : time())));
+        displayoptionids.push("id");
+
+        displayoptions.push(format("%23s: %s", "title",
+            ("title" in menu ? menu.title : "")));
+        displayoptionids.push("title");
+
+        displayoptions.push(format("%23s: %s", "sort_type",
+            ("sort_type" in menu ? menu.sort_type : "(default)")));
+        displayoptionids.push("sort_type");
+
+        displayoptions.push(format("%23s: %s", "Edit Items", "[...]"));
+        displayoptionids.push("items");
+        
+        selection = uifc.list(WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC, 0, 0, 0, last, last,
+            menu.title + ": Options", displayoptions);
+
+        if (selection < 0) {
+            // escape key
+            break;
+        }
+
+        editproperty = displayoptionids[selection];
+
+        switch (editproperty) {
+            case 'id':
+                uifc.help_text = word_wrap("This is a unique ID for the menu, which can be used as the target to\r\ncall the menu from other menus.\r\n\r\nFor the top-level menu, the id should be 'main'.");
+                var selection2 = uifc.input(WIN_MID, "Menu ID", menu.id, 50, K_EDIT);
+                if ((selection2 < 0) || (selection2 == null)) {
+                    // escape key
+                    break;
+                }
+                menu.id = selection2;
+                break;
+            case 'title':
+                uifc.help_text = word_wrap("Title for the menu, to be shown at the top of the menu");
+                selection2 = uifc.input(WIN_MID, "Menu Title", menu.title, 255, K_EDIT);
+                if ((selection2 < 0) || (selection2 == null)) {
+                    // escape key
+                    break;
+                }
+                menu.title = selection2;
+                break;
+
+            case 'sort_type':
+                uifc.help_text = word_wrap("How to sort the menu:\r\nby input key\r\nby title\r\nnone (the items remain in the order they are in the config)");
+
+                switch (uifc.list(WIN_ORG | WIN_MID, "Sort Type", ["key", "title", "none"])) {
+                    case 0:
+                        menu.sort_type = "key";
+                        break;
+                    case 1:
+                        menu.sort_type = "title";
+                        break;
+                    case 2:
+                        delete menu.sort_type;
+                        break;
+                    default:
+                        //esc key
+                        break;
+                }
+
+            case 'items':
+                editItems(menuid);
+                break;
+
+            default:
+                // this isn't supposed to happen
+                uifc.msg("Unknown option");
+                break;
+        }
+
+        last = Math.max(selection, 0);
+    }
+}
+
+/**
+ * Edit menu items in a menu
+ * @param menuid
+ */
+var editItems = function(menuid) {
+    var menuindex, menu, selection, selection2, keyused, items = [], itemids = [];
+    var i, last = 0;
+
+    // cur bar top left width
+    var ctxm = new uifc.list.CTX(0, 0, 0, 0, 0);
+    
+    if (typeof menuid === "undefined") {
+        uifc.msg("Menu could not be found");
+        return;
+    } else {
+        for (i in menuconfig.menus) {
+            if (menuconfig.menus[i].id == menuid) {
+                menu = menuconfig.menus[i];
+                menuindex = i;
+            }
+        }
+    }
+    
+    if ((typeof menu.items == "undefined") || (menu.items.length == 0)) {
+        // no items, prompt them to make one
+        editItem(menu.id, 0);
+    }
+    
+
+    uifc.help_text = word_wrap("This menu allows editing the various items in this menu.\r\n\r\n"
+        + "If you leave input key blank, it will use an auto-generated number at display time.\r\n\r\n"
+        + "Choose a type first and the dropdown to choose tha target will allow you to select your target.\r\n\r\n"
+        + "Access string only applies to custom menu items. For external sections or external programs, use the access settings in scfg.\r\n\r\n");
+
+    while(1) {
+        items = [];
+        itemids = [];
+        for(i in menu.items) {
+            items.push(format(
+                "%6s %10s %s",
+                menu.items[i].input ? menu.items[i].input : '(auto)',
+                menu.items[i].type,
+                menu.items[i].title
+            ));
+            itemids.push(i);
+        }
+        // WIN_ORG = original menu
+        // WIN_MID = centered mid
+        // WIN_ACT = menu remains active after a selection
+        // WIN_ESC = screen is active when escape is pressed
+        // WIN_XTR = blank line to insert
+        // WIN_INS = insert key
+        // WIN_DEL = delete
+        // WIN_CUT = cut ctrl-x
+        // WIN_COPY = copy ctrl-c
+        // WIN_PUT = paste ctrl-v
+        // WIN_SAV = use context/save position
+        selection = uifc.list(
+            WIN_ORG|WIN_MID|WIN_ACT|WIN_ESC|WIN_XTR|WIN_INS|WIN_DEL|WIN_CUT|WIN_COPY|WIN_PASTE|WIN_SAV,
+            menu.title + ": Items",
+            items,
+            ctxm
+        );
+        
+        if (selection == -1) {
+            // esc key
+            break;
+        }
+
+        if ((selection & MSK_ON) == MSK_DEL) {
+            // delete item
+            selection &= MSK_OFF;
+            //renumber array so there are no gaps
+            var menuitems2 = [];
+            for (var i in menu.items) {
+                if (i != itemids[selection]) {
+                    menuitems2.push(menu.items[i]);
+                }
+            }
+            menu.items = menuitems2;
+        } else if (((selection & MSK_ON) == MSK_INS)) {
+            // new item from INSERT KEY
+            editItem(menuid, null);
+        } else if ((selection & MSK_ON) == MSK_COPY) {
+            // copy item
+            selection &= MSK_OFF;
+            copyitem = JSON.parse(JSON.stringify(menu.items[itemids[selection]])); // make copy
+        } else if ((selection & MSK_ON) == MSK_CUT) {
+            // cut item
+            selection &= MSK_OFF;
+            copyitem = menu.items[itemids[selection]];
+
+            //renumber array so there are no gaps
+            var menuitems2 = [];
+            for (var i in menu.items) {
+                if (i != itemids[selection]) {
+                    menuitems2.push(menu.items[i]);
+                }
+            }
+            menu.items = menuitems2;
+        } else if ((selection & MSK_ON) == MSK_PASTE) {
+            // paste item
+            selection &= MSK_OFF;
+
+            var oktopaste = true;
+
+            // only paste if there is an item copied
+            if ("type" in copyitem) {
+                // if item already exists in list, modify if since you can't have dupes (except empty input keys)
+                for (var i in menu.items) {
+                    if ((menu.items[i].input == copyitem.input) && (copyitem.input !== null) && (copyitem.input !== "")) {
+                        oktopaste = true;
+                        while(1) {
+                            selection2 = uifc.input(WIN_MID, "Enter New Input Key", "", 3, K_EDIT);
+                            if ((selection2 == -1) || (selection2 === "") || (selection2 === null)) {
+                                // escape key
+                                copyitem.input = null;
+                                break;
+                            }
+                            if (selection2 || selection2 === 0) {
+                                selection2 = selection2.toUpperCase();
+                                keyused = false;
+                                for (var j in menu.items) {
+                                    if (menu.items[j].input && (menu.items[j].input.toUpperCase() == selection2)) {
+                                        keyused = true;
+                                    }
+                                }
+                                if (keyused) {
+                                    uifc.msg("This input key is alread used for another item");
+                                    oktopaste = false;
+                                } else {
+                                    copyitem.input = selection2;
+                                    oktopaste = true;
+                                    break;
+                                }
+                            }
+                        }
+                        copyitem.input = selection2;
+                    }
+                }
+                if ((oktopaste) || (copyitem.input === "null") || (copyitem.input === "")) {
+                    var menuitems2 = [];
+                    for (i in menu.items) {
+                        menuitems2.push(menu.items[i]);
+                        // paste copied item after selected item
+						if (i == itemids[selection]) {
+							menuitems2.push(copyitem);
+                            ctxm.cur = i-1;
+						}
+                    }
+                    menu.items = menuitems2;
+                }
+            }
+        } else if (selection >= menu.items.length) {
+            // new item from blank line
+            editItem(menuid, null);
+        } else {
+            editItem(menuid, itemids[selection]);
+        }
+        last = Math.max(selection, 0);
+    }
+}
+
+/**
+ * Edit a specific menu item entry
+ * @param menuid
+ * @param itemindex
+ */
+var editItem = function(menuid, itemindex) {
+    var menu, menuindex, item;
+    var keyused, selection, selection2, i, last = 0;
+    var displayoptions = [], displayoptionids = [], newitems = [];
+    // used for building target selection
+    var custommenuitems = [], custommenuitemsids = [], custommenunames = [];
+
+    if (typeof menuid === "undefined") {
+        uifc.msg("Menu could not be found");
+        return;
+    } else {
+        for (i in menuconfig.menus) {
+            if (menuconfig.menus[i].id == menuid) {
+                menu = menuconfig.menus[i];
+                menuindex = i;
+            }
+        }
+    }
+
+    if (typeof menu.items[itemindex] === "undefined") {
+        // new item
+        menu.items.push({
+            "input": null,
+            "title": "New Item " + time(),
+            "type": null,
+            "target": null,
+            "access_string": null,
+        });
+        itemindex = menu.items.length - 1;
+        present_select_targettype(menu.items[itemindex]);
+    }
+    item = menu.items[itemindex];
+
+    var itemctx = new uifc.list.CTX(0,0,0,0,0);
+    while(1) {
+        displayoptions = [];
+        displayoptionids = [];
+
+        // setup display menu
+        displayoptions.push(format("%23s: %s", "input",
+            (("input" in item) && (item.input !== null) && (item.input !== "") ? item.input : "(auto)")));
+        displayoptionids.push("input");
+
+        displayoptions.push(format("%23s: %s", "title",
+            ("title" in item ? item.title : "")));
+        displayoptionids.push("title");
+
+        displayoptions.push(format("%23s: %s", "type",
+            ("type" in item ? item.type : "")));
+        displayoptionids.push("type");
+
+        displayoptions.push(format("%23s: %s", "target",
+            ("target" in item ? item.target : "")));
+        displayoptionids.push("target");
+
+        if (item.type == "custommenu") {
+            displayoptions.push(format("%23s: %s", "access_string",
+                ("access_string" in item ? item.access_string : "(default)")));
+            displayoptionids.push("access_string");
+        }
+
+        selection = uifc.list(WIN_ORG | WIN_MID | WIN_ACT | WIN_ESC,
+            menu.title + ": Item " + itemindex, displayoptions, itemctx);
+
+        if (selection < 0) {
+            if (!item.title || !item.type || !item.target) {
+                if (uifc.list(WIN_ORG | WIN_MID, "This item is missing required items.", ["Remove Item", "Edit Item"]) == 0) {
+                    // delete item and continue
+                    newitems = [];
+                    for (i in menu.items) {
+                        if (i != itemindex) {
+                            newitems.push(menu.items[i]);
+                        }
+                    }
+                    menu.items = newitems;
+                    break;
+                }
+            } else {
+                // leave menu
+                break;
+            }
+        }
+
+        switch (displayoptionids[selection]) {
+            
+            case 'input':
+                uifc.help_text = word_wrap("The input key to access this item. Can be anything except Q. Leave blank to auto-generate a number.");
+                selection2 = uifc.input(WIN_MID, "Input Key", item.input, 3, K_EDIT);
+                if ((selection2 < 0) || (selection2 == null)) {
+                    // escape key
+                    break;
+                }
+
+                if (selection2 !== "") {
+                    selection2 = selection2.toUpperCase();
+
+                    keyused = false;
+                    for (i in menu.items) {
+                        if ((menu.items[i].input === null) || (menu.items[i].input === "")) {
+                            // continue here as toUpperCase would break it, and they don't need to match
+                            // anyway because you can have multiple auto-assigned input items
+                            continue;
+                        }
+                        if ((menu.items[i].input.toUpperCase() == selection2) && (i != itemindex)) {
+                            keyused = true;
+                        }
+                    }
+
+                    if (keyused) {
+                        uifc.msg("This input key is already used by another item.");
+                    } else {
+                        item.input = selection2;
+                    }
+                } else {
+                    // save blank
+                    item.input = selection2;
+                }
+                break;
+
+            case 'title':
+                uifc.help_text = word_wrap("The menu item title.");
+                selection2 = uifc.input(WIN_MID, "Title", item.title, 255, K_EDIT);
+                if ((selection2 < 0) || (selection2 == null)) {
+                    // escape key
+                    break;
+                }
+                if (!selection2 && selection2 !== 0) {
+                    uifc.msg("Title is required.");
+                } else {
+                    item.title = selection2;
+                }
+                break;
+
+            case 'type':
+                present_select_targettype(item);
+                break;
+
+            case 'target':
+                present_select_target(item);
+                break;
+
+            case 'access_string':
+                uifc.help_text = word_wrap("The access string for the custom menu.\r\n\r\nOnly applies to custom menu items.\r\n\r\nExample: LEVEL 60");
+                selection2 = uifc.input(WIN_MID, "Access String", item.access_string, 255, K_EDIT);
+                if ((selection2 < 0) || (selection2 == null)) {
+                    // escape key
+                    break;
+                }
+                item.access_string = selection2;
+                break;
+                
+        }
+        last = Math.max(selection, 0);
+    }
+}
+
+function present_select_targettype(item)
+{
+    uifc.help_text = word_wrap(
+        "This is the type of target this item points to.\r\n\r\n"
+        + "custommenu is a custom menu defined in this tool.\r\n\r\n"
+        + "xtrnmenu is a standard Syncrhonet External Section Menu (refer to the scfg tool).\r\n\r\n"
+        + "xtrnprog is a direct link to an external program (refer to the scfg tool)");
+
+    var targetypectx = uifc.list.CTX(0, 0, 0, 0, 0);
+    if (typeof item.type !== "undefined") {
+        switch (item.type) {
+            case 'custommenu':
+                targetypectx.cur = 0;
+                targetypectx.bar = 0;
+                break;
+            case 'xtrnmenu':
+                targetypectx.cur = 1;
+                targetypectx.bar = 1;
+                break;
+            case 'xtrnprog':
+                targetypectx.cur = 2;
+                targetypectx.bar = 2;
+                break;
+        }
+    }
+    switch (uifc.list(WIN_ORG | WIN_MID | WIN_SAV,
+        "Target Type", ["custommenu", "xtrnmenu", "xtrnprog"], targetypectx)) {
+        case 0:
+            item.type = "custommenu";
+            break;
+        case 1:
+            item.type = "xtrnmenu"
+            break;
+        case 2:
+            item.type = "xtrnprog";
+            break;
+        default:
+            // includes escape key
+            break;
+    }
+
+    // convienence... enter target selection
+    present_select_target(item)    
+}
+
+function present_select_target(item)
+{
+    uifc.help_text = word_wrap("This is the ID of the custom menu, external program section, or external program to link to.");
+
+    var targetctx = uifc.list.CTX(0, 0, 0, 0, 0);
+
+    var custommenuitems = [];
+    var custommenuitemsids = [];
+    var custommenunames = [];
+    
+    var selection2;
+    
+    switch (item.type) {
+        case "custommenu":
+            // present list of custom menus
+            for (i in menuconfig.menus) {
+                custommenuitems.push(format("%23s: %s", menuconfig.menus[i].id, menuconfig.menus[i].title));
+                custommenuitemsids.push(menuconfig.menus[i].id);
+                custommenunames.push(menuconfig.menus[i].title);
+            }
+
+            if ((typeof item.target !== "undefined") && item.target) {
+                for (var p in custommenuitemsids) {
+                    if (custommenuitemsids[p] == item.target) {
+                        targetctx.cur = p;
+                        targetctx.bar = p;
+                    }
+                }
+            }
+
+            selection2 = uifc.list(WIN_ORG | WIN_MID | WIN_SAV, "Target", custommenuitems, targetctx);
+            if ((selection2 < 0) || (selection2 == null)) {
+                // escape key
+                break;
+            }
+
+            item.target = custommenuitemsids[selection2];
+
+            while(1) {
+                if (uifc.list(WIN_ORG | WIN_MID, "Replace item title with sections's name?", ["Yes", "No"]) == 0) {
+                    item.title = custommenunames[selection2]; // for external program, change title to program name
+                }
+                break;
+            }
+            break;
+
+        case "xtrnmenu":
+            // present list of external program sections
+            var seclist = [];
+            for (i in xtrn_area.sec_list) {
+                seclist.push({ code: xtrn_area.sec_list[i].code, name: xtrn_area.sec_list[i].name});
+            };
+            seclist.sort(sort_by_code);
+            
+            for (i in seclist) {
+                custommenuitems.push(format("%23s: %s", seclist[i].code, seclist[i].name));
+                custommenuitemsids.push(seclist[i].code);
+                custommenunames.push(seclist[i].name);
+            }
+
+            if ((typeof item.target !== "undefined") && item.target) {
+                for (var p in custommenuitemsids) {
+                    if (custommenuitemsids[p].toLowerCase() == item.target.toLowerCase()) {
+                        targetctx.cur = p;
+                        targetctx.bar = p;
+                    }
+                }
+            }
+
+            selection2 = uifc.list(WIN_ORG | WIN_MID | WIN_SAV, "Target", custommenuitems, targetctx);
+            if ((selection2 < 0) || (selection2 == null)) {
+                // escape key
+                break;
+            }
+
+            item.target = custommenuitemsids[selection2];
+
+            while(1) {
+                if (uifc.list(WIN_ORG | WIN_MID, "Replace item title with sections's name?", ["Yes", "No"]) == 0) {
+                    item.title = custommenunames[selection2]; // for external program, change title to program name
+                }
+                break;
+            }
+            break;
+
+        case "xtrnprog":
+
+            // present list of external programs
+            // create sorted list
+            var proglist = [];
+
+            for (i in xtrn_area.prog) {
+                proglist.push({ code: xtrn_area.prog[i].code, name: xtrn_area.prog[i].name});
+            };
+            proglist.sort(sort_by_code);
+            for (i in proglist) {
+                custommenuitems.push(format("%23s: %s", proglist[i].code, proglist[i].name));
+                custommenuitemsids.push(proglist[i].code);
+                custommenunames.push(proglist[i].name);
+            }
+
+            if ((typeof item.target !== "undefined") && item.target) {
+                for (var p in custommenuitemsids) {
+                    if (custommenuitemsids[p].toLowerCase() == item.target.toLowerCase()) {
+                        targetctx.cur = p;
+                        targetctx.bar = p;
+                    }
+                }
+            }
+
+            selection2 = uifc.list(WIN_ORG | WIN_MID | WIN_SAV, "Target", custommenuitems, targetctx);
+            if ((selection2 < 0) || (selection2 == null)) {
+                // escape key
+                break;
+            }
+            if (selection2 || selection2 === 0) {
+                item.target = custommenuitemsids[selection2];
+                while(1) {
+                    if (uifc.list(WIN_ORG | WIN_MID, "Replace item title with sections's name?", ["Yes", "No"]) == 0) {
+                        item.title = custommenunames[selection2]; // for external program, change title to program name
+                    }
+                    break;
+                }
+            }
+            break;
+
+        default:
+            selection2 = uifc.input(WIN_ORG | WIN_MID, "Target", item.target, 50, K_EDIT);
+            if ((selection2 < 0) || (selection2 == null)) {
+                // escape key
+                break;
+            }
+
+            item.target = selection2;
+            break;
+    }    
+}
+
+function sort_by_name(a, b)
+{
+    if (a.name.toLowerCase() > b.name.toLowerCase()) return 1;
+    if (a.name.toLowerCase() < b.name.toLowerCase()) return -1;
+    return 0;
+}
+
+function sort_by_code(a, b)
+{
+    if (a.code.toLowerCase() > b.code.toLowerCase()) return 1;
+    if (a.code.toLowerCase() < b.code.toLowerCase()) return -1;
+    return 0;
+}
+
+function sort_by_id(a, b)
+{
+    if (a.id.toLowerCase() > b.id.toLowerCase()) return 1;
+    if (a.id.toLowerCase() < b.id.toLowerCase()) return -1;
+    return 0;
+}
+
+
+// MAIN
+try {
+    var menuconfig = {};
+    var copyitem = {}; // for menu item copy/paste
+    var config_file = new File(file_cfgname(system.ctrl_dir, "xtrnmenu.cfg"));
+    if (config_file.open('r+')) {
+        var config_src = config_file.read();
+        try {
+            menuconfig = JSON.parse(config_src.toString());
+            if (!menuconfig) {
+                writeln("ERROR! Could not parse xtrnmenu.cfg. JSON may be invalid.");
+                exit();
+            }
+        } catch (e) {
+            writeln("ERROR! Could not parse xtrnmenu.cfg. JSON may be invalid. " + e.toString());
+            exit();
+        }
+    }
+    config_file.close();
+    
+    if (typeof menuconfig.menus === "undefined") {
+        menuconfig.menus = [];
+    }
+
+    uifc.init("Enhanced External Program Menus Configurator");
+    uifc.lightbar_color = 120;
+    uifc.background_color = 21; 
+    uifc.frame_color = 15;
+    js.on_exit("if (uifc.initialized) uifc.bail()");
+
+    // cur bar top left width
+    var ctx = new uifc.list.CTX(0, 0, 0, 0, 0);
+
+    while(1) {
+        uifc.help_text = word_wrap("This program allows managing the Enhanced External Program Menu feature.");
+
+        // no menus or no main menu
+        var mainmenufound = false;
+        for (var m in menuconfig.menus) {
+            if (menuconfig.menus[m].id.toLowerCase() == "main") {
+                mainmenufound = true;
+            }
+        }
+        if (!mainmenufound || (menuconfig.menus.length < 1)) {
+            uifc.msg("No menus defined and/or missing the main menu. Setting up now.");
+            editMenu("main");
+        }
+
+        // menus is array of menuconfig menu ids
+        var menus = [];
+        var menuTitles = [];
+        menuconfig.menus.sort(sort_by_id);
+        for (var m in menuconfig.menus) {
+            menus.push(menuconfig.menus[m].id);
+            menuTitles.push(format("%20s: %s", menuconfig.menus[m].id, menuconfig.menus[m].title));
+        }
+        
+        menuTitles.push(format("%20s  %s", '', "[Save Config Without Exit]"));
+        
+        // WIN_ORG = original menu, destroy valid screen area
+        // WIN_MID = place window in middle of screen
+        // WIN_XTR = add extra line at end for inserting at end
+        // WIN_DEL = allow user to use delete key
+        // WIN_ACT = menu remains active after a selection
+        // WIN_ESC = screen is active when escape is hit
+        // WIN_INS = allow user to use insert key
+        var selection = uifc.list(
+            WIN_ORG|WIN_MID|WIN_XTR|WIN_DEL|WIN_ACT|WIN_ESC|WIN_INS|WIN_SAV,
+            "Enhanced External Menus",
+            menuTitles,
+            ctx
+        );
+
+        if (selection == (menuTitles.length - 1)) {
+            // last item - save config
+            saveMenus();
+            uifc.pop("Config saved.");
+        } else if (selection < 0) {
+            while (1) {
+                var ret = uifc.list(WIN_ORG | WIN_MID, "Save Changes Before Exit?", ["Yes", "No", "Cancel"]);
+                if (ret == 0) {
+                    saveMenus();
+                    exit();
+                } else if (ret == 1) {
+                    // no - exit
+                    exit();
+                } else {
+                    // cancel
+                    break;
+                }
+            }
+
+        } else if ((selection & MSK_ON) == MSK_DEL) {
+            selection &= MSK_OFF;
+            for (var m in menuconfig.menus) {
+                if (menuconfig.menus[m].id == menus[selection]) {
+                    delete menuconfig.menus[m];
+                }
+            }
+            //selection--;
+        } else if (((selection & MSK_ON) == MSK_INS) || (selection >= menuconfig.menus.length)) {
+            // new menu
+            var newid = uifc.input(
+                WIN_MID,
+                "Enter a short unique id for the menu",
+                "",
+                0
+            );
+            if (typeof newid !== "undefined") {
+                var menufound = false;
+                for (var mf in menuconfig.menus) {
+                    if (menuconfig.menus[mf].id == newid) {
+                        menufound = true;
+                    }
+                }
+                if (menufound) {
+                    uifc.msg("That ID is already in use. Please choose another.");
+                } else {
+                    editMenu(newid);
+                }
+            }
+        } else {
+            editMenu(menuconfig.menus[selection].id);
+        }
+    }
+} catch(err) {
+    if ((typeof uifc !== "undefined") && uifc.initialized) {
+        uifc.bail();
+    }
+    writeln(err);
+    log(err);
+    if (typeof console !== "undefined") {
+        console.pause();
+    }
+}
diff --git a/webv4/pages/099-xtrnmenu-games.xjs b/webv4/pages/099-xtrnmenu-games.xjs
new file mode 100644
index 0000000000000000000000000000000000000000..c7106969f157d3872695bbe5d7e9b140abcde545
--- /dev/null
+++ b/webv4/pages/099-xtrnmenu-games.xjs
@@ -0,0 +1,149 @@
+<!--HIDDEN:Games-->
+<?xjs
+/**
+ * Web Display for Custom External Program Menus
+ * by Michael Long mlong  innerrealmbbs.us
+ * 
+ * See wiki at http://wiki.synchro.net/module:xtrnmenumod
+ */
+
+    load("ftelnethelper.js");
+    load('sbbsdefs.js');
+    load("xtrnmenulib.js");
+    load(settings.web_lib + 'ftelnet.js');
+    load(settings.web_lib + 'request.js');
+
+    var ExternalMenus = new ExternalMenus();
+
+    if (typeof settings.xtrn_blacklist === 'string') {
+        settings.xtrn_blacklist = settings.xtrn_blacklist.toLowerCase().split(',');
+    } else {
+        settings.xtrn_blacklist = [];
+    }
+
+    var menuitems = [];
+?>
+
+<style>.fTelnetStatusBar { display : none; }</style>
+
+<a name="fTelnet"></a>
+<div id="fTelnetContainer" class="fTelnetContainer" style="margin-bottom:1em;"></div>
+
+<div id="xtrn-list" class="list-group">
+
+<?xjs
+    var menuobj;
+    if ((Request.get_param('type') == 'xtrnmenu') && Request.has_param('target')) {
+        menuobj = ExternalMenus.getSectionMenu(Request.get_param('target'));
+    } else {
+        if (Request.has_param('target')) {
+            menuobj = ExternalMenus.getMenu(Request.get_param('target'));
+        } else {
+            menuobj = ExternalMenus.getMenu('main');
+        }
+    }
+
+    if ((typeof menuobj === "undefined") || !menuobj
+        || (typeof menuobj.items === "undefined") || (menuobj.items.length < 1)) {
+        writeln("<h4>" + ExternalMenus.options.no_programs_msg + "</h4>");
+    } else if (user.security.restrictions&UFLAG_X) {
+        writeln("<h4>" + options.restricted_user_msg + "</h4>");
+    } else {
+        // ok to display menu
+
+        writeln("<h4>" + menuobj.title + "</h4>");
+        var menuitemsfiltered = ExternalMenus.getSortedItems(menuobj);
+        menuitemsfiltered.forEach(function (menuitem) {
+            if (settings.xtrn_blacklist.indexOf(menuitem.target.toLowerCase()) > -1) {
+                return;
+            }
+            menuitems.push({
+                'itemtitle': menuitem.title,
+                'itemtype': menuitem.type,
+                'itemtarget': menuitem.target
+            });
+        });
+    }
+?>
+</div>
+
+<script id="fTelnetScript" src="<?xjs write(get_url()); ?>"></script>
+<script type="text/javascript">
+    var wsp = <?xjs write(settings.wsp || GetWebSocketServicePort()); ?>;
+    var wssp = <?xjs write(settings.wssp || GetWebSocketServicePort(true)); ?>;
+    var Options = new fTelnetOptions();
+    Options.BareLFtoCRLF = false;
+    Options.BitsPerSecond = 57600;
+    Options.ConnectionType = 'rlogin';
+    Options.Emulation = 'ansi-bbs';
+    Options.Enter = '\r';
+    Options.Font = 'CP437';
+    Options.ForceWss = false;
+    Options.Hostname = '<?xjs write(http_request.vhost); ?>';
+    Options.LocalEcho = false;
+    Options.Port = location.protocol == 'https:' ? wssp : wsp;
+    Options.RLoginClientUsername = '<?xjs write(user.security.password); ?>';
+    Options.RLoginServerUsername = '<?xjs write(user.alias); ?>';
+    Options.ScreenColumns = 80;
+    Options.ScreenRows = 25;
+    Options.SplashScreen = Options.SplashScreen = '<?xjs write(get_splash()); ?>';
+    Options.WebSocketUrlPath = '?Port=<?xjs write(GetRLoginPort()); ?>';
+    var fTelnet = new fTelnetClient('fTelnetContainer', Options);
+    fTelnet.OnConnectionClose = function () {
+        window.location.reload();
+    };
+
+    async function launchXtrn() {
+        var code = event.srcElement.id;
+        await v4_get('./api/system.ssjs?call=set-xtrn-intent&code=' + code);
+        fTelnet._Options.RLoginTerminalType = 'xtrn=' + code;
+        fTelnet.Connect();
+    }
+
+    var menuitems = <?xjs write(JSON.stringify(menuitems)); ?>;
+    var currentTarget = "<?xjs Request.write_param('target'); ?>";
+    var currentType = "<?xjs Request.write_param('type'); ?>";
+    var currentTitle = `<?xjs write(menuobj.title) ?>`;
+    if (currentTitle && !currentTarget) {
+        // main menu - store title for breadcrumb
+        sessionStorage.setItem("mainmenu", currentTitle);
+    }
+    var div = $('#xtrn-list');
+    menuitems.forEach(function (menuitem) {
+        var a = document.createElement('a');
+        $(a).addClass("list-group-item");
+        $(a).addClass("striped");
+        a.text = menuitem.itemtitle;
+
+        if (menuitem.itemtype == "xtrnprog") {
+            a.href = "#fTelnet";
+            a.id = menuitem.itemtarget;
+            a.onclick = function () { launchXtrn(); };
+        } else {
+            a.href = "/?page=<?xjs Request.write_param('page') ?>&type=" +menuitem.itemtype
+                + "&target=" + menuitem.itemtarget;
+            a.onclick = function () {
+                sessionStorage.setItem('prev:' + menuitem.itemtarget.toLowerCase(), '<?xjs Request.write_param('target')?>');
+                sessionStorage.setItem('prevtype:' + menuitem.itemtarget.toLowerCase(), '<?xjs Request.write_param('type')?>');
+                sessionStorage.setItem('prevtitle:' + menuitem.itemtarget.toLowerCase(), currentTitle);
+            };
+        }
+        $(div).append(a);
+    });
+
+    // breadcrumb
+    var prevTarget = sessionStorage.getItem('prev:' + currentTarget.toLowerCase());
+    var prevType = sessionStorage.getItem('prevtype:' + currentTarget.toLowerCase());
+    var prevTitle = sessionStorage.getItem('prevtitle:' + currentTarget.toLowerCase());
+    if (prevType && prevTarget && prevTitle) {
+        $('#xtrn-list').prepend('<ol class="breadcrumb"><a href="/?page=<?xjs Request.write_param('page') ?>&type=' + prevType
+        + '&target=' + prevTarget + '">' + prevTitle + '</a></ol>');
+    } else if (currentTarget) {
+        // level 2, not main menu
+        var mainmenuTitle = sessionStorage.getItem('mainmenu');
+        if (!mainmenuTitle) {
+            mainmenuTitle = 'Games';
+        }
+        $('#xtrn-list').prepend('<ol class="breadcrumb"><a href="/?page=<?xjs Request.write_param('page') ?>">' + mainmenuTitle + '</a></ol>');
+    }
+</script>