diff --git a/op-mode-definitions/restart-serial.xml.in b/op-mode-definitions/restart-serial.xml.in
new file mode 100644
index 000000000..4d8a03633
--- /dev/null
+++ b/op-mode-definitions/restart-serial.xml.in
@@ -0,0 +1,31 @@
+<?xml version="1.0"?>
+<interfaceDefinition>
+  <node name="restart">
+    <children>
+      <node name="serial">
+        <properties>
+          <help>Restart services on serial ports</help>
+        </properties>
+        <children>
+          <node name="console">
+            <properties>
+              <help>Restart serial console service for login TTYs</help>
+            </properties>
+            <command>sudo ${vyos_op_scripts_dir}/serial.py restart_console</command>
+            <children>
+              <tagNode name="device">
+                <properties>
+                  <help>Restart specific TTY device</help>
+                  <completionHelp>
+                    <script>${vyos_completion_dir}/list_login_ttys.py</script>
+                  </completionHelp>
+                </properties>
+                <command>sudo ${vyos_op_scripts_dir}/serial.py restart_console --device-name "$5"</command>
+              </tagNode>
+            </children>
+          </node>
+        </children>
+      </node>
+    </children>
+  </node>
+</interfaceDefinition>
diff --git a/python/vyos/utils/serial.py b/python/vyos/utils/serial.py
new file mode 100644
index 000000000..b646f881e
--- /dev/null
+++ b/python/vyos/utils/serial.py
@@ -0,0 +1,118 @@
+# Copyright 2024 VyOS maintainers and contributors <maintainers@vyos.io>
+#
+# This library is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public
+# License as published by the Free Software Foundation; either
+# version 2.1 of the License, or (at your option) any later version.
+#
+# This library is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this library.  If not, see <http://www.gnu.org/licenses/>.
+
+import os, re, json
+from typing import List
+
+from vyos.base import Warning
+from vyos.utils.io import ask_yes_no
+from vyos.utils.process import cmd
+
+GLOB_GETTY_UNITS = 'serial-getty@*.service'
+RE_GETTY_DEVICES = re.compile(r'.+@(.+).service$')
+
+SD_UNIT_PATH = '/run/systemd/system'
+UTMP_PATH = '/run/utmp'
+
+def get_serial_units(include_devices=[]):
+    # Since we cannot depend on the current config for decommissioned ports,
+    # we just grab everything that systemd knows about.
+    tmp = cmd(f'systemctl list-units {GLOB_GETTY_UNITS} --all --output json --no-pager')
+    getty_units = json.loads(tmp)
+    for sdunit in getty_units:
+        m = RE_GETTY_DEVICES.search(sdunit['unit'])
+        if m is None:
+            Warning(f'Serial console unit name "{sdunit["unit"]}" is malformed and cannot be checked for activity!')
+            continue
+
+        getty_device = m.group(1)
+        if include_devices and getty_device not in include_devices:
+            continue
+
+        sdunit['device'] = getty_device
+
+    return getty_units
+
+def get_authenticated_ports(units):
+    connected = []
+    ports = [ x['device'] for x in units if 'device' in x ]
+    #
+    # utmpdump just gives us an easily parseable dump of currently logged-in sessions, for eg:
+    # $ utmpdump /run/utmp
+    # Utmp dump of /run/utmp
+    # [2] [00000] [~~  ] [reboot  ] [~           ] [6.6.31-amd64-vyos   ] [0.0.0.0        ] [2024-06-18T13:56:53,958484+00:00]
+    # [1] [00051] [~~  ] [runlevel] [~           ] [6.6.31-amd64-vyos   ] [0.0.0.0        ] [2024-06-18T13:57:01,790808+00:00]
+    # [6] [03178] [tty1] [LOGIN   ] [tty1        ] [                    ] [0.0.0.0        ] [2024-06-18T13:57:31,015392+00:00]
+    # [7] [37151] [ts/0] [vyos    ] [pts/0       ] [10.9.8.7            ] [10.9.8.7       ] [2024-07-04T13:42:08,760892+00:00]
+    # [8] [24812] [ts/1] [        ] [pts/1       ] [10.9.8.7            ] [10.9.8.7       ] [2024-06-20T18:10:07,309365+00:00]
+    #
+    # We can safely skip blank or LOGIN sessions with valid device names.
+    #
+    for line in cmd(f'utmpdump {UTMP_PATH}').splitlines():
+        row = line.split('] [')
+        user_name = row[3].strip()
+        user_term = row[4].strip()
+        if user_name and user_name != 'LOGIN' and user_term in ports:
+            connected.append(user_term)
+
+    return connected
+
+def restart_login_consoles(prompt_user=False, quiet=True, devices: List[str]=[]):
+    # restart_login_consoles() is called from both conf- and op-mode scripts, including
+    # the warning messages and user prompts common to both.
+    #
+    # The default case, called with no arguments, is a simple serial-getty restart &
+    # cleanup wrapper with no output or prompts that can be used from anywhere.
+    #
+    # quiet and prompt_user args have been split from an original "no_prompt", in
+    # order to support the completely silent default use case. "no_prompt" would
+    # only suppress the user interactive prompt.
+    #
+    # quiet intentionally does not suppress a vyos.base.Warning() for malformed
+    # device names in _get_serial_units().
+    #
+    cmd('systemctl daemon-reload')
+
+    units = get_serial_units(devices)
+    connected = get_authenticated_ports(units)
+
+    if connected:
+        if not quiet:
+            Warning('There are user sessions connected via serial console that '\
+                    'will be terminated when serial console settings are changed!')
+            if not prompt_user:
+                # This flag is used by conf_mode/system_console.py to reset things, if there's
+                # a problem, the user should issue a manual restart for serial-getty.
+                Warning('Please ensure all settings are committed and saved before issuing a ' \
+                      '"restart serial console" command to apply new configuration!')
+        if not prompt_user:
+            return False
+        if not ask_yes_no('Any uncommitted changes from these sessions will be lost\n' \
+                          'and in-progress actions may be left in an inconsistent state.\n'\
+                          '\nContinue?'):
+            return False
+
+    for unit in units:
+        if 'device' not in unit:
+            continue # malformed or filtered.
+        unit_name = unit['unit']
+        unit_device = unit['device']
+        if os.path.exists(os.path.join(SD_UNIT_PATH, unit_name)):
+            cmd(f'systemctl restart {unit_name}')
+        else:
+            # Deleted stubs don't need to be restarted, just shut them down.
+            cmd(f'systemctl stop {unit_name}')
+
+    return True
diff --git a/src/completion/list_login_ttys.py b/src/completion/list_login_ttys.py
new file mode 100644
index 000000000..4d77a1b8b
--- /dev/null
+++ b/src/completion/list_login_ttys.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from vyos.utils.serial import get_serial_units
+
+if __name__ == '__main__':
+    # Autocomplete uses runtime state rather than the config tree, as a manual 
+    # restart/cleanup may be needed for deleted devices. 
+    tty_completions = [ '<text>' ] + [ x['device'] for x in get_serial_units() if 'device' in x ]
+    print(' '.join(tty_completions))
+
+
diff --git a/src/conf_mode/system_console.py b/src/conf_mode/system_console.py
index 19bbb8875..27bf92e0b 100755
--- a/src/conf_mode/system_console.py
+++ b/src/conf_mode/system_console.py
@@ -1,149 +1,148 @@
 #!/usr/bin/env python3
 #
 # Copyright (C) 2020-2024 VyOS maintainers and contributors
 #
 # This program is free software; you can redistribute it and/or modify
 # it under the terms of the GNU General Public License version 2 or later as
 # published by the Free Software Foundation.
 #
 # This program is distributed in the hope that it will be useful,
 # but WITHOUT ANY WARRANTY; without even the implied warranty of
 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 # GNU General Public License for more details.
 #
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import os
 from pathlib import Path
 
 from vyos.config import Config
 from vyos.utils.process import call
+from vyos.utils.serial import restart_login_consoles
 from vyos.system import grub_util
 from vyos.template import render
+from vyos.defaults import directories
 from vyos import ConfigError
 from vyos import airbag
 airbag.enable()
 
 by_bus_dir = '/dev/serial/by-bus'
 
 def get_config(config=None):
     if config:
         conf = config
     else:
         conf = Config()
     base = ['system', 'console']
 
     # retrieve configuration at once
     console = conf.get_config_dict(base, get_first_key=True)
 
     # bail out early if no serial console is configured
     if 'device' not in console:
         return console
 
     for device, device_config in console['device'].items():
         if 'speed' not in device_config and device.startswith('hvc'):
             # XEN console has a different default console speed
             console['device'][device]['speed'] = 38400
 
     console = conf.merge_defaults(console, recursive=True)
 
     return console
 
 def verify(console):
     if not console or 'device' not in console:
         return None
 
     for device in console['device']:
         if device.startswith('usb'):
             # It is much easiert to work with the native ttyUSBn name when using
             # getty, but that name may change across reboots - depending on the
             # amount of connected devices. We will resolve the fixed device name
             # to its dynamic device file - and create a new dict entry for it.
             by_bus_device = f'{by_bus_dir}/{device}'
             # If the device name still starts with usbXXX no matching tty was found
             # and it can not be used as a serial interface
             if not os.path.isdir(by_bus_dir) or not os.path.exists(by_bus_device):
                 raise ConfigError(f'Device {device} does not support beeing used as tty')
 
     return None
 
 def generate(console):
     base_dir = '/run/systemd/system'
     # Remove all serial-getty configuration files in advance
     for root, dirs, files in os.walk(base_dir):
         for basename in files:
             if 'serial-getty' in basename:
-                call(f'systemctl stop {basename}')
                 os.unlink(os.path.join(root, basename))
 
     if not console or 'device' not in console:
         return None
 
     # replace keys in the config for ttyUSB items to use them in `apply()` later
     for device in console['device'].copy():
         if device.startswith('usb'):
             # It is much easiert to work with the native ttyUSBn name when using
             # getty, but that name may change across reboots - depending on the
             # amount of connected devices. We will resolve the fixed device name
             # to its dynamic device file - and create a new dict entry for it.
             by_bus_device = f'{by_bus_dir}/{device}'
             if os.path.isdir(by_bus_dir) and os.path.exists(by_bus_device):
                 device_updated = os.path.basename(os.readlink(by_bus_device))
 
                 # replace keys in the config to use them in `apply()` later
                 console['device'][device_updated] = console['device'][device]
                 del console['device'][device]
             else:
                 raise ConfigError(f'Device {device} does not support beeing used as tty')
 
     for device, device_config in console['device'].items():
         config_file = base_dir + f'/serial-getty@{device}.service'
         Path(f'{base_dir}/getty.target.wants').mkdir(exist_ok=True)
         getty_wants_symlink = base_dir + f'/getty.target.wants/serial-getty@{device}.service'
 
         render(config_file, 'getty/serial-getty.service.j2', device_config)
         os.symlink(config_file, getty_wants_symlink)
 
     # GRUB
     # For existing serial line change speed (if necessary)
     # Only applys to ttyS0
     if 'ttyS0' not in console['device']:
         return None
 
     speed = console['device']['ttyS0']['speed']
     grub_util.update_console_speed(speed)
 
     return None
 
 def apply(console):
     # Reset screen blanking
     call('/usr/bin/setterm -blank 0 -powersave off -powerdown 0 -term linux </dev/tty1 >/dev/tty1 2>&1')
     # Reload systemd manager configuration
     call('systemctl daemon-reload')
 
+    # Service control moved to vyos.utils.serial to unify checks and prompts. 
+    # If users are connected, we want to show an informational message on completing 
+    # the process, but not halt configuration processing with an interactive prompt. 
+    restart_login_consoles(prompt_user=False, quiet=False)
+
     if not console:
         return None
 
     if 'powersave' in console.keys():
         # Configure screen blank powersaving on VGA console
         call('/usr/bin/setterm -blank 15 -powersave powerdown -powerdown 60 -term linux </dev/tty1 >/dev/tty1 2>&1')
 
-    # Start getty process on configured serial interfaces
-    for device in console['device']:
-        # Only start console if it exists on the running system. If a user
-        # detaches a USB serial console and reboots - it should not fail!
-        if os.path.exists(f'/dev/{device}'):
-            call(f'systemctl restart serial-getty@{device}.service')
-
     return None
 
 if __name__ == '__main__':
     try:
         c = get_config()
         verify(c)
         generate(c)
         apply(c)
     except ConfigError as e:
         print(e)
         exit(1)
diff --git a/src/op_mode/serial.py b/src/op_mode/serial.py
new file mode 100644
index 000000000..a5864872b
--- /dev/null
+++ b/src/op_mode/serial.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+#
+# Copyright (C) 2024 VyOS maintainers and contributors
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 or later as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import sys, typing
+
+import vyos.opmode
+from vyos.utils.serial import restart_login_consoles as _restart_login_consoles
+
+def restart_console(device_name: typing.Optional[str]):
+    # Service control moved to vyos.utils.serial to unify checks and prompts. 
+    # If users are connected, we want to show an informational message and a prompt
+    # to continue, verifying that the user acknowledges possible interruptions. 
+    if device_name:
+        _restart_login_consoles(prompt_user=True, quiet=False, devices=[device_name])
+    else:
+        _restart_login_consoles(prompt_user=True, quiet=False)
+
+if __name__ == '__main__':
+    try:
+        res = vyos.opmode.run(sys.modules[__name__])
+        if res:
+            print(res)
+    except (ValueError, vyos.opmode.Error) as e:
+        print(e)
+        sys.exit(1)