28 KiB
SPEC-013: Windows Session Selection and Backstage Mode
Status: Proposed Priority: P2 Requested By: Mike Swanson (2026-05-30) Estimated Effort: Large
Overview
Enable GuruConnect to enumerate and switch between multiple Windows user sessions (Terminal Services/RDP/Fast User Switching) and access Session 0 (backstage mode) for system-level administrative tasks. This addresses a critical gap for MSPs managing multi-user Windows servers and workstations — ScreenConnect's session selector allows technicians to see all logged-on users and switch between them instantly, and the backstage mode provides a command/task-manager interface for Session 0 (services) without disrupting any user's desktop. Success criteria: technicians can view and select from all active sessions in the dashboard/viewer, switch sessions mid-stream without reconnecting, and enter backstage mode to run commands and view services when no user desktop is needed.
Scope
Included in v1
Session Enumeration and Switching:
- Agent enumerates all Windows sessions via
WTSEnumerateSessionsAPI at startup and on-demand - Reports session list to server: session ID, user name, session state (active/disconnected), session type (console/RDP)
- Viewer displays session selector UI (dropdown or list) showing all available sessions
- User selects a session → server sends
SwitchSessionmessage → agent switches capture/input to that session - Session switching without WebSocket disconnect (same session token, just changes the desktop being captured)
- Dashboard shows session count and logged-on users per machine
Backstage Mode (Session 0 Access):
- Agent can enter "backstage" mode targeting Session 0 (services session)
- Backstage mode provides a terminal/command interface (not DXGI screen capture, since Session 0 has no interactive desktop in modern Windows)
- Dashboard/viewer shows backstage UI with:
- Command prompt / PowerShell terminal (PTY-like, text-based)
- Running services list (via
EnumServicesStatusEx) - Running processes list (via
EnumProcesses/CreateToolhelp32Snapshot) - System info panel (OS version, uptime, logged-on sessions)
- Backstage mode uses
SessionType::BACKSTAGE(already in protobuf line 42) - Agent runs commands in Session 0 context via
CreateProcessAsUserwith Session 0 token
Protobuf Extensions:
- New messages:
SessionInfo,SessionList,SwitchSession,BackstageCommand,BackstageOutput - Extend
AgentStatusto include session list - Extend
StartStreamto specify target session ID
Security:
- Session switching requires elevated (admin) agent process
- Backstage mode is admin-only (gated server-side by user role)
- Audit log records all session switches and backstage commands
Explicitly out of scope
- Session shadowing (viewing a session while another user is active in it) — defer to Phase 2; complex Terminal Services permission model
- Session connect/disconnect control (logging users off, disconnecting RDP sessions) — defer; high-impact action
- Cross-session clipboard — clipboard currently session-scoped; multi-session clipboard is a future enhancement
- Linux/macOS session switching — this spec is Windows-only; cross-platform session concepts differ significantly
- Backstage GUI tools (registry editor, event log viewer) — v1 is terminal/command-only; GUI tools are future enhancements
- Session 0 screen capture — Session 0 has no interactive desktop on modern Windows (Session 0 isolation, Vista+); backstage is terminal/data-only
Architecture
Agent Changes
Session Management Module (agent/src/session/mod.rs or new agent/src/sessions/)
-
Enumerate sessions:
use windows::Win32::System::RemoteDesktop::{ WTSEnumerateSessionsW, WTSFreeMemory, WTSQuerySessionInformationW, WTS_SESSION_INFOW, WTSUserName, WTSSessionInfo, WTS_CURRENT_SERVER_HANDLE }; pub struct SessionInfo { pub session_id: u32, pub user_name: String, pub state: SessionState, // Active, Disconnected, Idle pub session_type: SessionType, // Console, RDP } pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> { // Call WTSEnumerateSessionsW // For each session, call WTSQuerySessionInformationW(WTSUserName, WTSSessionInfo) // Filter out Session 0 (services) from user session list } -
Switch session desktop:
use windows::Win32::System::RemoteDesktop::WTSGetActiveConsoleSessionId; use windows::Win32::UI::WindowsAndMessaging::{OpenDesktop, CloseDesktop, SwitchDesktop}; pub struct SessionContext { pub session_id: u32, pub desktop_handle: HDESK, // Handle to the session's desktop } pub fn switch_to_session(session_id: u32) -> Result<SessionContext> { // OpenDesktop for the target session (desktop name: "WinSta0\\Default" or session-specific) // SwitchDesktop to make it the active desktop for capture // Reinitialize DXGI capturer on the new desktop } -
Backstage mode (Session 0 command execution):
use windows::Win32::System::Threading::{CreateProcessAsUserW, PROCESS_INFORMATION}; use windows::Win32::Security::{LogonUserW, DuplicateTokenEx}; pub struct BackstageSession { pub session_id: u32, // Always 0 for backstage pub process_handle: HANDLE, pub stdout_pipe: HANDLE, } pub fn execute_backstage_command(command: &str) -> Result<BackstageOutput> { // CreateProcessAsUserW with Session 0 token (obtained via WTSQueryUserToken for Session 0) // Capture stdout/stderr via anonymous pipes // Return output as text (no screen capture) } pub fn list_services() -> Result<Vec<ServiceInfo>> { // EnumServicesStatusEx(SC_MANAGER_ENUMERATE_SERVICE) } pub fn list_processes() -> Result<Vec<ProcessInfo>> { // CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) }
Capture Module Changes (agent/src/capture/mod.rs, agent/src/capture/dxgi.rs)
- Refactor
DxgiCapturer::new()to accept asession_idparameter OpenDesktopfor the target session before initializing DXGI duplication- Store current session ID in capturer state; reinitialize on session switch
- Backstage mode does NOT use DXGI — it's terminal/data-only
Input Injection Changes (agent/src/input/mod.rs)
- Ensure
SendInputtargets the correct session's desktop (already handles this via desktop handle) - Backstage mode does NOT inject input (command execution only)
New Files:
agent/src/sessions/mod.rs— session enumeration, switching, backstage executionagent/src/sessions/wts.rs— WTS API wrappers (enumerate, query, get token)agent/src/sessions/backstage.rs— Session 0 command execution, service/process enumeration
Relay Server Changes
Session State Management (server/src/relay/mod.rs or server/src/session/)
-
Extend
SessionStatestruct to track current session ID:pub struct SessionState { pub session_id: String, pub session_type: SessionType, // SCREEN_CONTROL, VIEW_ONLY, BACKSTAGE pub target_session_id: Option<u32>, // Windows session ID (1, 2, 3, etc.) or 0 for backstage // ... existing fields } -
Handle
SessionListmessage from agent (periodic update of available sessions) -
Store session list in
SessionStatefor dashboard queries -
Handle
SwitchSessionmessage from viewer → forward to agent -
Handle
BackstageCommandmessage from viewer → forward to agent -
Handle
BackstageOutputmessage from agent → forward to viewer
API Endpoints (server/src/api/sessions.rs)
-
GET /api/sessions/:session_id/windows-sessions— return list of Windows sessions for this agent{ "sessions": [ {"id": 1, "user": "jdoe", "state": "Active", "type": "Console"}, {"id": 2, "user": "admin", "state": "Disconnected", "type": "RDP"} ] } -
Backstage commands are sent via WebSocket, not HTTP (same as mouse/keyboard input)
Database Schema (server/migrations/)
-
Extend
connect_sessionstable:ALTER TABLE connect_sessions ADD COLUMN windows_session_id INT NULL; ALTER TABLE connect_sessions ADD COLUMN session_type VARCHAR(20) DEFAULT 'screen_control'; -
Extend
eventstable to log session switches:INSERT INTO events (session_id, event_type, details) VALUES ('...', 'session_switched', '{"from_session": 1, "to_session": 2, "user": "admin"}');
Protobuf Changes (proto/guruconnect.proto)
// Session information (Windows Terminal Services session)
message SessionInfo {
uint32 session_id = 1; // Windows session ID (1, 2, 3, etc.)
string user_name = 2; // Logged-on user name
SessionState state = 3; // Active, Disconnected, Idle
WindowsSessionType type = 4; // Console, RDP
}
enum SessionState {
SESSION_ACTIVE = 0;
SESSION_DISCONNECTED = 1;
SESSION_IDLE = 2;
}
enum WindowsSessionType {
SESSION_CONSOLE = 0; // Local console session
SESSION_RDP = 1; // Remote Desktop session
}
// List of available Windows sessions (agent -> server)
message SessionList {
repeated SessionInfo sessions = 1;
uint32 current_session_id = 2; // Which session the agent is currently capturing
}
// Switch to a different Windows session (viewer -> agent)
message SwitchSession {
uint32 target_session_id = 1; // Session ID to switch to (0 for backstage)
}
// Backstage command execution (viewer -> agent)
message BackstageCommand {
BackstageCommandType command_type = 1;
string command_text = 2; // For EXEC_COMMAND: PowerShell or CMD command
}
enum BackstageCommandType {
BACKSTAGE_EXEC_COMMAND = 0; // Execute a command in Session 0
BACKSTAGE_LIST_SERVICES = 1; // Enumerate running services
BACKSTAGE_LIST_PROCESSES = 2; // Enumerate running processes
BACKSTAGE_SYSTEM_INFO = 3; // Get system information
}
// Backstage output (agent -> viewer)
message BackstageOutput {
BackstageCommandType command_type = 1;
oneof output {
CommandOutput command_output = 10;
ServiceList service_list = 11;
ProcessList process_list = 12;
SystemInfo system_info = 13;
}
}
message CommandOutput {
string stdout = 1;
string stderr = 2;
int32 exit_code = 3;
}
message ServiceList {
repeated ServiceInfo services = 1;
}
message ServiceInfo {
string name = 1;
string display_name = 2;
ServiceStatus status = 3; // Running, Stopped, Paused
string startup_type = 4; // Automatic, Manual, Disabled
}
enum ServiceStatus {
SERVICE_RUNNING = 0;
SERVICE_STOPPED = 1;
SERVICE_PAUSED = 2;
}
message ProcessList {
repeated ProcessInfo processes = 1;
}
message ProcessInfo {
uint32 pid = 1;
string name = 2;
string user = 3;
uint64 memory_kb = 4;
}
message SystemInfo {
string os_version = 1;
string hostname = 2;
uint64 uptime_secs = 3;
repeated SessionInfo logged_on_sessions = 4;
}
// Extend Message wrapper to include new message types
message Message {
oneof payload {
// ... existing messages ...
// Session switching (field numbers 90-99)
SessionList session_list = 90;
SwitchSession switch_session = 91;
BackstageCommand backstage_command = 92;
BackstageOutput backstage_output = 93;
}
}
Dashboard/Viewer Changes
Dashboard (server/static/dashboard.html or React component)
- Machine detail view shows "Sessions (3)" next to online status
- Click sessions count → modal showing session list with Switch button
- "Enter Backstage" button for admin users (gated by
user.role === 'admin')
Native Viewer (agent/src/viewer/mod.rs)
- Session selector dropdown in viewer toolbar
- Populate dropdown from
SessionListmessage - On selection change → send
SwitchSessionmessage - "Backstage Mode" button (toggle) — switches to terminal UI instead of video frames
Web Viewer (dashboard/src/components/RemoteViewer.tsx)
- Same session selector dropdown as native viewer
- Backstage mode shows a terminal-like UI (xterm.js or custom) displaying command output
- Text input for commands, buttons for List Services / List Processes / System Info
Implementation Details
Files to Create
Agent:
agent/src/sessions/mod.rs(300 lines) — session enumeration, switching, stateagent/src/sessions/wts.rs(200 lines) — WTS API wrappersagent/src/sessions/backstage.rs(400 lines) — Session 0 command execution, service/process enum
Server:
server/src/relay/sessions.rs(150 lines) — session list state, switch logicserver/migrations/00N_session_selection.sql(50 lines) — schema changes
Dashboard:
server/static/js/session-selector.js(200 lines) — session dropdown UIserver/static/css/backstage.css(100 lines) — backstage terminal styling
Files to Modify
Agent:
agent/src/capture/dxgi.rs:42— Add session ID parameter toDxgiCapturer::new()agent/src/capture/mod.rs:76— CallOpenDesktopfor target session before DXGI initagent/src/session/mod.rs:120— Extend session loop to handleSwitchSessionandBackstageCommandagent/src/main.rs:15— Addmod sessions;
Server:
server/src/relay/mod.rs:85— HandleSessionList,SwitchSession,BackstageCommand,BackstageOutputmessagesserver/src/api/sessions.rs:40— Add endpoint for session list queryserver/src/db/events.rs:25— Log session switch events
Proto:
proto/guruconnect.proto:454— Add new message types (as shown above)
Key Logic
Agent Session Enumeration:
// agent/src/sessions/wts.rs
use windows::Win32::System::RemoteDesktop::{
WTSEnumerateSessionsW, WTSQuerySessionInformationW, WTSFreeMemory,
WTS_SESSION_INFOW, WTSUserName, WTSConnectState, WTS_CURRENT_SERVER_HANDLE
};
pub fn enumerate_sessions() -> Result<Vec<SessionInfo>> {
let mut sessions = Vec::new();
let mut session_info_ptr: *mut WTS_SESSION_INFOW = std::ptr::null_mut();
let mut count: u32 = 0;
unsafe {
if WTSEnumerateSessionsW(
WTS_CURRENT_SERVER_HANDLE,
0, // Reserved
1, // Version
&mut session_info_ptr,
&mut count
).as_bool() {
let session_array = std::slice::from_raw_parts(session_info_ptr, count as usize);
for wts_session in session_array {
// Skip Session 0 (services) for user session list
if wts_session.SessionId == 0 {
continue;
}
// Query user name
let mut user_name_ptr: *mut u16 = std::ptr::null_mut();
let mut bytes_returned: u32 = 0;
if WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE,
wts_session.SessionId,
WTSUserName,
&mut user_name_ptr as *mut _ as *mut _,
&mut bytes_returned
).as_bool() {
let user_name = String::from_utf16_lossy(
std::slice::from_raw_parts(user_name_ptr, (bytes_returned / 2) as usize)
);
sessions.push(SessionInfo {
session_id: wts_session.SessionId,
user_name,
state: map_wts_state(wts_session.State),
session_type: if wts_session.pWinStationName.to_string()?.contains("RDP") {
WindowsSessionType::RDP
} else {
WindowsSessionType::Console
}
});
WTSFreeMemory(user_name_ptr as *mut _);
}
}
WTSFreeMemory(session_info_ptr as *mut _);
}
}
Ok(sessions)
}
Agent Session Switching:
// agent/src/sessions/mod.rs
use windows::Win32::UI::WindowsAndMessaging::{
OpenDesktopW, CloseDesktop, SwitchDesktop, HDESK
};
use windows::core::PCWSTR;
pub struct SessionManager {
current_session_id: u32,
desktop_handle: Option<HDESK>,
}
impl SessionManager {
pub fn switch_session(&mut self, target_session_id: u32) -> Result<()> {
tracing::info!("Switching from session {} to {}", self.current_session_id, target_session_id);
// Close current desktop handle
if let Some(handle) = self.desktop_handle.take() {
unsafe { CloseDesktop(handle) };
}
// Open the target session's desktop
// Desktop name format: "WinSta0\\Default" for console session
let desktop_name = format!("WinSta0\\Default");
let desktop_name_wide: Vec<u16> = desktop_name.encode_utf16().chain(Some(0)).collect();
let desktop_handle = unsafe {
OpenDesktopW(
PCWSTR::from_raw(desktop_name_wide.as_ptr()),
0, // Flags
false.into(), // Inherit handles
0x0001 | 0x0002 | 0x0004 | 0x0008, // DESKTOP_READOBJECTS | DESKTOP_CREATEWINDOW | etc.
)?
};
// Switch to the new desktop
unsafe {
SwitchDesktop(desktop_handle)?;
}
self.current_session_id = target_session_id;
self.desktop_handle = Some(desktop_handle);
// Reinitialize screen capturer for the new desktop
// (caller must recreate DxgiCapturer)
Ok(())
}
}
Backstage Command Execution:
// agent/src/sessions/backstage.rs
use windows::Win32::System::Threading::{CreateProcessW, PROCESS_INFORMATION, STARTUPINFOW};
use windows::Win32::System::Pipes::{CreatePipe, ReadFile};
pub fn execute_command(command: &str) -> Result<CommandOutput> {
// Create anonymous pipes for stdout/stderr
let mut stdout_read: HANDLE = HANDLE::default();
let mut stdout_write: HANDLE = HANDLE::default();
unsafe {
CreatePipe(&mut stdout_read, &mut stdout_write, None, 0)?;
}
// Launch cmd.exe /c <command> with redirected output
let command_line = format!("cmd.exe /c {}", command);
let mut command_line_wide: Vec<u16> = command_line.encode_utf16().chain(Some(0)).collect();
let mut startup_info = STARTUPINFOW {
cb: std::mem::size_of::<STARTUPINFOW>() as u32,
hStdOutput: stdout_write,
hStdError: stdout_write,
dwFlags: STARTF_USESTDHANDLES,
..Default::default()
};
let mut process_info = PROCESS_INFORMATION::default();
unsafe {
CreateProcessW(
None,
PWSTR::from_raw(command_line_wide.as_mut_ptr()),
None,
None,
true.into(), // Inherit handles
0,
None,
None,
&startup_info,
&mut process_info
)?;
CloseHandle(stdout_write);
// Read output
let mut output = Vec::new();
let mut buffer = [0u8; 4096];
let mut bytes_read: u32 = 0;
loop {
if ReadFile(stdout_read, Some(&mut buffer), Some(&mut bytes_read), None).is_ok() {
if bytes_read == 0 {
break;
}
output.extend_from_slice(&buffer[..bytes_read as usize]);
} else {
break;
}
}
// Wait for process to exit
WaitForSingleObject(process_info.hProcess, INFINITE);
let mut exit_code: u32 = 0;
GetExitCodeProcess(process_info.hProcess, &mut exit_code)?;
CloseHandle(process_info.hProcess);
CloseHandle(process_info.hThread);
CloseHandle(stdout_read);
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output).to_string(),
stderr: String::new(),
exit_code: exit_code as i32,
})
}
}
Security Considerations
Authentication & Authorization
- Session switching requires elevated agent: Agent must run as SYSTEM or Administrator to call WTS APIs and
OpenDesktopacross sessions - Backstage mode is admin-only: Server checks
user.role === 'admin'before allowingBackstageCommandmessages - Audit logging: All session switches and backstage commands are logged to
eventstable with:- Timestamp
- Technician user ID
- Session ID (from/to)
- Command text (for backstage)
- Output (sanitized, no secrets)
Input Validation
- Session ID bounds checking: Agent validates
target_session_idexists inWTSEnumerateSessionsresults before switching - Command injection prevention: Backstage commands are executed via
cmd.exe /cwith proper escaping; no shell metacharacter expansion - Output sanitization: Command output is truncated to 100KB max to prevent memory exhaustion
Threat Model
- Malicious session switch: Attacker with viewer access tries to switch to a privileged session (e.g., admin RDP session) → mitigated by server-side session ownership validation (can only switch sessions on machines you have access to)
- Backstage command abuse: Attacker executes destructive commands (e.g.,
rd /s /q C:\) → mitigated by admin-only role gate + audit logging - Session 0 privilege escalation: Attacker uses backstage to run code as SYSTEM → inherent risk, same as any admin tool; audit log is the control
Audit Events
New event types:
session_switched— Log when technician switches Windows sessionsbackstage_command— Log all backstage command executionsbackstage_service_query— Log service list queriesbackstage_process_query— Log process list queries
Event schema:
CREATE TABLE events (
id UUID PRIMARY KEY,
session_id UUID REFERENCES connect_sessions(id),
user_id UUID REFERENCES users(id),
event_type VARCHAR(50) NOT NULL,
details JSONB NOT NULL, -- {"from_session": 1, "to_session": 2, "command": "ipconfig", "exit_code": 0}
created_at TIMESTAMPTZ DEFAULT NOW()
);
Testing Strategy
Unit Tests
Agent (Rust):
sessions::wts::test_enumerate_sessions()— mock WTS API, verify session parsingsessions::backstage::test_execute_command()— mock CreateProcess, verify output capturesessions::test_switch_session()— mock OpenDesktop/SwitchDesktop, verify state change
Server (Rust):
relay::sessions::test_session_list_update()— verify session list parsing and storagerelay::sessions::test_switch_session_message()— verify message forwarding to agentapi::sessions::test_get_sessions_endpoint()— verify API response format
Integration Tests
End-to-end session switching:
- Start agent on Windows machine with 2 logged-on users (console + RDP)
- Connect viewer to session
- Agent sends
SessionListwith 2 sessions - Viewer sends
SwitchSession(2) - Agent switches to RDP session, restarts DXGI capture
- Viewer receives frames from RDP session desktop
- Verify session switch logged in
eventstable
Backstage command execution:
- Admin user connects to agent
- Enter backstage mode
- Execute command:
ipconfig /all - Verify output displayed in viewer
- Execute command:
sc query wuauserv(query Windows Update service) - Verify service status returned
- Verify commands logged with full details
Manual Testing Scenarios
-
Multi-user server:
- Windows Server 2019 with 3 RDP users logged in
- Enumerate sessions → verify all 3 shown in dropdown
- Switch between sessions → verify screen updates correctly
- Verify no session 0 in dropdown (backstage mode is separate)
-
Fast User Switching workstation:
- Windows 10 workstation with 2 users (one active, one locked)
- Enumerate sessions → verify both shown with correct state
- Switch to locked session → verify lock screen is captured
-
Backstage mode:
- Connect to agent
- Enter backstage → verify no video frames, just terminal UI
- Run
hostname→ verify output - List services → verify service table displayed
- List processes → verify process list
- Exit backstage → return to normal screen control
-
Permission enforcement:
- Non-admin user tries to enter backstage → verify 403 Forbidden
- Non-admin user tries to send
BackstageCommand→ server rejects
-
Audit logging:
- Perform session switch + backstage commands
- Query
eventstable → verify all actions logged with timestamps, user IDs, details
CI/CD Additions
- Windows test VM: Gitea Actions Windows runner with 2 test users configured (via Local Users and Groups)
- Automated test: PowerShell script to create RDP session, run integration test, verify session switch
- Audit log verification: Integration test queries events table after actions, asserts correct event_type and details
Effort Estimate & Dependencies
Size: Large (8-10 weeks, 1 developer)
Breakdown:
- Agent session enumeration + WTS API wrappers: 1.5 weeks
- Agent session switching (OpenDesktop, SwitchDesktop, DXGI reinit): 2 weeks
- Agent backstage mode (command execution, service/process enum): 2 weeks
- Server session state management + protobuf: 1 week
- Dashboard session selector UI: 1 week
- Backstage terminal UI (web viewer): 1.5 weeks
- Testing (integration tests, manual scenarios, audit verification): 1.5 weeks
- Buffer for Windows session edge cases (disconnected sessions, fast user switching quirks): 1 week
Dependencies:
- SPEC-002 v2 Phase 1 completion — per-agent keys and secure session core must be stable (already shipped)
- Admin role enforcement — server must have role-based access control for backstage gating (already exists:
users.role) - Event logging infrastructure — audit events table and logging (already exists:
eventstable)
Unblocks:
- Multi-user server management (critical for MSPs with Windows Server environments)
- Session 0 administrative tasks without GUI disruption (backstage mode)
- SPEC-005 machines list view (can show session count and logged-on users per machine)
- Future: session recording (can record specific session, not just active console)
Open Questions
-
Session 0 GUI in older Windows? — Windows XP/Server 2003 allowed interactive Session 0. GuruConnect targets Windows 7+ where Session 0 is non-interactive. Confirm backstage terminal-only approach is acceptable, or add a "force capture Session 0 desktop" option for legacy systems?
-
Session switching during active control? — If technician is moving the mouse and switches sessions mid-action, should the agent queue the switch until input is idle, or switch immediately? (Recommend: immediate switch, input events are session-scoped.)
-
Disconnected session handling? — If technician switches to a disconnected RDP session, should the agent attempt to reconnect it (via
WTSConnectSession), or just capture the "disconnected" screen? (Recommend: just capture; reconnection is a high-impact action.) -
Backstage command timeout? — Long-running commands (e.g.,
chkdsk) could hang. Implement a timeout (e.g., 60 seconds) and kill the process? (Recommend: yes, 60s default, configurable.) -
Clipboard across sessions? — Current clipboard implementation is session-scoped. Should session switching clear the clipboard state, or try to preserve it? (Recommend: clear on switch; cross-session clipboard is a future enhancement.)
Cross-references:
- SPEC-002: v2 modernization (protobuf already has
BACKSTAGE = 2) - SPEC-005: Machines list view (can show session count + logged-on users)
- SPEC-008: Structured errors (session switch failures need clear error codes)
- REQUIREMENTS.md:498 — "Backstage Tools (No Screen Required)"
- REQUIREMENTS.md:727 — "Backstage / Silent Support Mode"