// Extracted from newOS4 index.html — WebX11Terminal class WebX11SecurityManager { constructor(terminal, level = 1) { this.terminal = terminal; this.level = level; this.commandMeta = new Map(); this.lastRouteTime = 0; this.lastRouteCmd = ''; this.blockedCount = 0; this._baseDestructive = ['delete','del','write','save','overwrite','format','kill','stop','shutdown','reboot']; this._baseSystem = ['reboot','shutdown','format','kill','halt','poweroff']; this._baseSuspiciousFlags = ['--dump','--execute','--eval','--exec','--shell','--script']; this._baseWhitelist = ['help','clear','ls','cat','pwd','whoami','df','free','top','ps','echo','date','env','history','find','grep','tree','head','tail','sed','wc','sort','du','diff','which','hostname','cal','basename','dirname','yes','seq','cut','uniq','expand','printf','base64','sleep','ll','h','mem','status','health-check','system-health','alias','unalias','modules','listmods','modules-list','nvfs-index','nvfs-status','nvfs-resolve','nvfs-tree','nvfs-config','nvfs-alias','nvfs-sync','floating-add','floating-remove','floating-list','floating-pos','floating-clear','floating-toggle','mem-debug','reset-mem','reset','factory-reset','reset-vfs','js','js-run','node','debug','debug-ai','debug-stats','debug-history','debug-analyze','debug-trends','debug-fix','debug-health','session-new','orchestrate','orchestrate-parallel','orchestrate-decompose','orchestrate-result','orchestrator-status','orchestrator-spawn','orchestrator-kill','orchestrator-purge','orchestrator-config','orchestrator-list','orchestrator-sleep','orchestrator-wake','orchestrator-restart-policy','a2a-send','a2a-list','a2a-post','a2a-poll','a2a-ack','a2a-reject','a2a-status','a2a-prune','consensus','consensus-status','consensus-history','pipeline','agent-list','agent-register','agent-status','agent-unregister','contract-list','contract-register','voice','speak','analyze','dashboard','context-auto', // 'ui' is a SAFE, non-destructive switch command. It used to be known only while the // shell module was registered, so in classic the security layer classified it unknown // (level 1) and routed it to the AI instead of dispatching — "Ui command don't work" // (2026-11-12). Never leave a navigation command dependent on the UI it navigates. 'ui']; this.destructiveCmds = new Set(this._baseDestructive); this.systemCmds = new Set(this._baseSystem); this.suspiciousFlags = new Set(this._baseSuspiciousFlags); this.whitelist = new Set(this._baseWhitelist); // AI loop-guard: depth counter (reentrant, race-safe vs parallel tool calls) this._aiToolDepth = 0; // Load persisted user deltas (add/remove) so hardcoded defaults can be demoted. this._loadSecurityDeltas(); this._registerBuiltIn(); } _loadSecurityDeltas() { try { const schema = this.terminal._getDataSchemaVersion ? this.terminal._getDataSchemaVersion() : 1; localStorage.setItem('webx11_data_schema_version', String(schema)); const raw = localStorage.getItem('webx11_security_deltas'); if (!raw) { // One-time migration from legacy flat keys to delta format. const legacy = {}; try { legacy.destructive = JSON.parse(localStorage.getItem('webx11_security_destructive') || '[]'); } catch (e) {} try { legacy.system = JSON.parse(localStorage.getItem('webx11_security_system') || '[]'); } catch (e) {} try { legacy.whitelist = JSON.parse(localStorage.getItem('webx11_security_whitelist') || '[]'); } catch (e) {} try { legacy.flags = JSON.parse(localStorage.getItem('webx11_security_flags') || '[]'); } catch (e) {} const delta = { add: {}, remove: {} }; for (const c of (legacy.destructive || [])) if (!this._baseDestructive.includes(c)) { if (!delta.add.destructive) delta.add.destructive = []; delta.add.destructive.push(c); } for (const c of (legacy.system || [])) if (!this._baseSystem.includes(c)) { if (!delta.add.system) delta.add.system = []; delta.add.system.push(c); } for (const c of (legacy.whitelist || [])) if (!this._baseWhitelist.includes(c)) { if (!delta.add.whitelist) delta.add.whitelist = []; delta.add.whitelist.push(c); } for (const f of (legacy.flags || [])) if (!this._baseSuspiciousFlags.includes(f)) { if (!delta.add.flags) delta.add.flags = []; delta.add.flags.push(f); } localStorage.setItem('webx11_security_deltas', JSON.stringify(delta)); localStorage.removeItem('webx11_security_destructive'); localStorage.removeItem('webx11_security_system'); localStorage.removeItem('webx11_security_whitelist'); localStorage.removeItem('webx11_security_flags'); return; } const d = JSON.parse(raw); if (d.remove) { for (const c of (d.remove.destructive || [])) this.destructiveCmds.delete(c); for (const c of (d.remove.system || [])) this.systemCmds.delete(c); for (const c of (d.remove.whitelist || [])) this.whitelist.delete(c); for (const f of (d.remove.flags || [])) this.suspiciousFlags.delete(f); } if (d.add) { for (const c of (d.add.destructive || [])) this.destructiveCmds.add(c); for (const c of (d.add.system || [])) this.systemCmds.add(c); for (const c of (d.add.whitelist || [])) this.whitelist.add(c); for (const f of (d.add.flags || [])) this.suspiciousFlags.add(f); } } catch (e) {} } _saveSecurityDeltas() { const delta = { add: {}, remove: {} }; for (const c of this.destructiveCmds) if (!this._baseDestructive.includes(c)) { if (!delta.add.destructive) delta.add.destructive = []; delta.add.destructive.push(c); } for (const c of this._baseDestructive) if (!this.destructiveCmds.has(c)) { if (!delta.remove.destructive) delta.remove.destructive = []; delta.remove.destructive.push(c); } for (const c of this.systemCmds) if (!this._baseSystem.includes(c)) { if (!delta.add.system) delta.add.system = []; delta.add.system.push(c); } for (const c of this._baseSystem) if (!this.systemCmds.has(c)) { if (!delta.remove.system) delta.remove.system = []; delta.remove.system.push(c); } for (const c of this.whitelist) if (!this._baseWhitelist.includes(c)) { if (!delta.add.whitelist) delta.add.whitelist = []; delta.add.whitelist.push(c); } for (const c of this._baseWhitelist) if (!this.whitelist.has(c)) { if (!delta.remove.whitelist) delta.remove.whitelist = []; delta.remove.whitelist.push(c); } for (const f of this.suspiciousFlags) if (!this._baseSuspiciousFlags.includes(f)) { if (!delta.add.flags) delta.add.flags = []; delta.add.flags.push(f); } for (const f of this._baseSuspiciousFlags) if (!this.suspiciousFlags.has(f)) { if (!delta.remove.flags) delta.remove.flags = []; delta.remove.flags.push(f); } localStorage.setItem('webx11_security_deltas', JSON.stringify(delta)); } _registerBuiltIn() { const internals = ['clear','help','ls','cat','df','free','env','history','cd','pwd','touch','mkdir','rm','cp','mv','edit','run','font-size','load-local','loadlocal','mem','security','security-level','security-destructive','security-system','security-whitelist','security-flag','config','ai-config','startup-modules','smart-prompt','cmd-allow','cmd-deny','system-health','server','servers','server-check','monitor','vfs','vfs-clean','vfs-size','empty-trash','expand','sysmsg','orchestrate','orchestrate-parallel','orchestrate-decompose','orchestrate-result','orchestrator-status','orchestrator-spawn','orchestrator-kill','orchestrator-purge','orchestrator-config','orchestrator-list','orchestrator-sleep','orchestrator-wake','orchestrator-restart-policy','a2a-send','a2a-list','a2a-post','a2a-poll','a2a-ack','a2a-reject','a2a-status','a2a-prune','consensus','consensus-status','consensus-history','pipeline','agent-list','agent-register','agent-status','agent-unregister','contract-list','contract-register','voice','speak','analyze','dashboard']; internals.forEach(c => this.registerCommand(c, { category: 'builtin' })); } setLevel(n) { if (n >= 0 && n <= 3) { this.level = n; this.terminal.print(`Security level set to ${n} (${this.getLevelName()})`, 'success'); if (n === 0) { this.terminal.print('Warning: Permissive mode — commands will not be blocked or confirmed.', 'warning'); } } else { this.terminal.print('Level must be 0-3', 'error'); } } getLevel() { return this.level; } getLevelName() { return ['Permissive','Standard','Paranoid','Audit'][this.level] || 'Unknown'; } registerCommand(name, meta = {}) { this.commandMeta.set(name, { destructive: false, category: 'user', ...meta }); this.invalidateCommandCache(); } invalidateCommandCache() { this._commandCacheDirty = true; } _collectAllCommands() { if (!this._commandCacheDirty && this._commandCache) return this._commandCache; const cmds = new Set([...this.commandMeta.keys()]); for (const modName in this.terminal.moduleManager.modules) { const mod = this.terminal.moduleManager.modules[modName]; if (mod.commands) { for (const c in mod.commands) cmds.add(c); } } const builtins = ['clear','help','ls','df','security','security-level']; builtins.forEach(c => cmds.add(c)); this._commandCache = [...cmds]; this._commandCacheDirty = false; return this._commandCache; } _levenshtein(a, b) { a = a.toLowerCase(); b = b.toLowerCase(); const m = a.length, n = b.length; if (!m) return n; if (!n) return m; let prev = new Uint16Array(n + 1); let curr = new Uint16Array(n + 1); for (let j = 0; j <= n; j++) prev[j] = j; for (let i = 1; i <= m; i++) { curr[0] = i; for (let j = 1; j <= n; j++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost); } [prev, curr] = [curr, prev]; } return prev[n]; } suggest(input, maxDist = 2) { const all = this._collectAllCommands(); const lower = input.toLowerCase(); let best = '', bestDist = Infinity; for (const c of all) { const d = this._levenshtein(lower, c); if (d < bestDist && d <= maxDist) { bestDist = d; best = c; } } return best ? { cmd: best, dist: bestDist } : null; } _parseArgs(input) { const args = []; let current = ''; let inDouble = false; let inSingle = false; for (const ch of input) { if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (ch === ' ' && !inDouble && !inSingle) { if (current) { args.push(current); current = ''; } } else { current += ch; } } if (current) args.push(current); return args; } _classify(raw) { const parts = this._parseArgs(String(raw || '').trim()); // `help |` and `| wc -l` produce an empty pipeline stage; parts[0] was undefined // and every such invocation threw TypeError from this line (989 occurrences, // first filed 2026-10-20). Classify the empty command as inert instead. if (!parts.length) { return { cmd: '', args: [], known: true, destructive: false, system: false, unknown: false, hasSuspicious: false }; } const cmd = parts[0].toLowerCase(); const args = parts.slice(1); // Internal commands always allowed at terminal level, but security still classifies const all = this._collectAllCommands(); const known = all.includes(cmd); const hasSuspicious = args.some(a => this.suspiciousFlags.has(a.toLowerCase())); const destructive = this.destructiveCmds.has(cmd) || hasSuspicious; const system = this.systemCmds.has(cmd); const unknown = !known; return { cmd, args, known, destructive, system, unknown, hasSuspicious }; } // v4.6.58: this terminal has no shell. `;` at least failed loudly, but `&&`/`||` // folded the right-hand side into the left command's argv, and a `|` arriving from // the AI run_command tool (which bypasses splitPipe) degraded to an empty pattern // matching every line. Both are silent wrong answers — refuse instead (packet §1). detectComposition(input) { const s = String(input || ''); let inSingle = false, inDouble = false; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (inSingle || inDouble) continue; if (ch === '&' && s[i + 1] === '&') return '&&'; if (ch === '|' && s[i + 1] === '|') return '||'; if (ch === '|') return '|'; if (ch === ';') return ';'; } return null; } async checkAndRun(rawInput) { // v4.6.130: keep the untokenised line for the command itself. _parseArgs turns // quote characters into delimiters and drops them, so `js "abc".toUpperCase()` // reaches the module as `abc.toUpperCase()` and every string literal dies. // Security classification still runs on the tokenised form below. this._currentRaw = rawInput; const badOp = this.detectComposition(rawInput); if (badOp) { this.terminal.print('⚠️ No shell here — `' + badOp + '` is not interpreted. ' + 'Run one command per line (the interactive terminal supports `cmd | cmd`; ' + 'AI run_command does not).', 'error'); return; } const { cmd, args, known, destructive, system, unknown, hasSuspicious } = this._classify(rawInput); // Level 3 Audit: just log and allow if (this.level === 3) { this.terminal.print(`[AUDIT] ${rawInput} → known=${known} destructive=${destructive} system=${system}`, 'info'); return this._dispatch(cmd, args); } // Always allow builtins that are safe, regardless of level if (['help','security','security-level'].includes(cmd)) { return this._dispatch(cmd, args); } // Unknown command handling — skip at level 0 (permissive) if (unknown && !this.whitelist.has(cmd)) { if (this.level === 0) { return this._dispatch(cmd, args); } this.blockedCount++; const sug = this.suggest(cmd); // At level 2+: block entirely (paranoid) if (this.level >= 2) { this.terminal.print(`Security: Unknown command "${cmd}" blocked (level ${this.level}).${sug ? ` Did you mean: "${sug.cmd}"?` : ''}`, 'error'); return; } // Level 1: typo of a safe whitelisted command → one-tap clickable fix, no AI call burned if (sug && sug.dist <= 1 && this.whitelist.has(sug.cmd) && this.terminal.showSuggestionBar && !(this._aiToolDepth > 0)) { this.terminal.print(`Unknown command "${cmd}" — did you mean:`, 'warning'); this.terminal.showSuggestionBar(sug.cmd, args); return; } // AI-invoked commands must not route back to AI (infinite loop) — return structured error if (this._aiToolDepth > 0) { this.terminal.print(`Command not found: ${cmd}${sug ? `. Did you mean: ${sug.cmd}?` : ''}`, 'error'); return; } // Level 1 fallback: route to AI (terminal is also a chat interface) if (sug) { this.terminal.print(`Security: Unknown command "${cmd}" — routing to AI (did you mean "${sug.cmd}"?)...`, 'warning'); } else { this.terminal.print(`Security: Unknown command "${cmd}" — routing to AI...`, 'info'); } return this._tryAIRoute(rawInput); } // Suspicious flags (e.g. --execute) at any level > 0 if (hasSuspicious && this.level > 0) { this.terminal.print(`Security: Suspicious flag detected in "${rawInput}" — blocked.`, 'error'); this.blockedCount++; return; } // Destructive commands if (destructive) { if (this.level === 0) { this.terminal.print(`Warning: destructive command "${cmd}"`, 'warning'); return this._dispatch(cmd, args); } if (this.level >= 1) { // Reuse your existing confirm UI if present, otherwise print confirm message if (this.terminal.showConfirmationBar) { this.terminal._pendingCommand = () => this._dispatch(cmd, args); this.terminal.showConfirmationBar(`Run destructive: ${rawInput}`); return; } else { this.terminal.print(`Security: Destructive command "${cmd}" requires confirmation.`, 'warning'); return; } } } // System commands at level >=1 if (system && this.level >= 1) { this.terminal.print(`Security: System command "${cmd}" blocked (level ${this.level}).`, 'error'); this.blockedCount++; return; } // Standard dispatch for everything else return this._dispatch(cmd, args); } async _dispatch(cmd, args) { return this.terminal.dispatchCommand(cmd, args, this._currentRaw || null); } async _tryAIRoute(rawInput) { // AI routing cooldown guard const now = Date.now(); if (this.lastRouteCmd === rawInput && (now - this.lastRouteTime) < 5000) { this.terminal.print(`Security: Already routed "${rawInput}" to AI recently. Use 'security-level 0' to force.`, 'warning'); return; } this.lastRouteCmd = rawInput; this.lastRouteTime = now; try { const aiMod = this.terminal.moduleManager.modules['WebX11AIAssistantModule']; if (aiMod) { this.terminal.print('→ Routing to AI assistant...', 'info'); await aiMod.chat([rawInput.trim()]); } } catch (e) { this.terminal.print(`Security: AI error: ${e.message}`, 'error'); } } } class WebX11Terminal { constructor() { this.outputContainer = document.getElementById('terminal-container'); this.inputField = document.getElementById('command-input'); this.dropdown = document.getElementById('autocomplete-dropdown'); this.statusBar = document.getElementById('status-text'); this.networkStatus = document.getElementById('network-status'); this.fs = new VirtualFileSystem(); this.secretVault = null; try { if (typeof SecretVault !== 'undefined') { this.secretVault = new SecretVault(this); window.webx11_secretVault = this.secretVault; } } catch (e) { console.warn('SecretVault init:', e.message); } this.moduleManager = new ModuleManager(this); this.security = new WebX11SecurityManager(this, parseInt(localStorage.getItem('webx11_security_level')) || 1); this.ollamaManager = new OllamaManager(this); // Config registry — modules can register keys for the unified `config` command this._configRegistry = {}; this._configDefaults = {}; this._configSetters = {}; this._loadTerminalConfig(); // Register built-in terminal config keys this.registerConfigItem('terminal-max-lines', 100, (v) => { this.MAX_OUTPUT_LINES = v; }); this.registerConfigItem('terminal-hist-output', 50, (v) => { this._historyMaxLines = v; }); this.registerConfigItem('terminal-cmd-history', 200, (v) => { /* applied on next save */ }); this.registerConfigItem('terminal-pipe-buffer', 5000, (v) => { this._pipeBufferSize = v; }); this.registerConfigItem('terminal-sysmsg', true, (v) => { this._showSystemMessages = !!v; }); this._pipeInput = null; this._pipeBufferSize = 5000; this._activeFollow = null; // Print-capture stack: [{output: ''}] — see print() head. LIFO order: // nested/parallel captures each get their own buffer. this._printCaptureStack = []; this._historyBuffer = []; this._historyMaxLines = 50; this._historySavePending = false; this.MAX_OUTPUT_LINES = 100; this._showSystemMessages = true; this._historyCap = 200; // Apply history cap from localStorage on startup (was applied "on next save" before — broken) try { const cfgCap = parseInt(localStorage.getItem('webx11_terminal_cmd_history')); if (cfgCap && cfgCap > 0) this._historyCap = cfgCap; } catch (e) {} this._aiBusy = false; this._dirty = false; this.history = JSON.parse(localStorage.getItem('webx11_command_history') || '[]'); this.historyIndex = this.history.length; this.selectedSuggestion = -1; this.suggestions = []; this._isCdAutocomplete = false; this._autocompletePrefix = ''; this._welcomeShown = false; this.initEventListeners(); this.updateNetworkStatus(); this.loadBuiltInModules(); this._loadHistory(); // Auto-restore session this._autoRestoreSession(); this._restoreFontSize(); // Restore saved theme on boot this._restoreTheme(); // Init auto-save timer — 60s after boot, then every 60s setTimeout(() => this._initAutoSave(), 30000); // Init sound toggle button text const soundBtn = document.getElementById('sound-toggle-btn'); if (soundBtn) { soundBtn.textContent = localStorage.getItem('webx11_config_ai_sound') === 'off' ? '🔇 Muted' : '🔊 Sound'; } setTimeout(() => this.inputField.focus(), 100); // First-run welcome check — fires after ext modules are loaded setTimeout(() => this._checkFirstRun(), 2000); // Init all registered modules (safe now — constructor is done) this._initRegisteredModules(); // Apply config overrides after everything is initialized this._applyTerminalConfig(); } /* ---------- helpers ---------- */ static _escapeHtml(str) { if (str == null) return ''; return String(str).replace(/[&<>'"]/g, m => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[m])); } _escapeHtml(str) { return WebX11Terminal._escapeHtml(str); } _getDataSchemaVersion() { return 1; } _escapeYamlString(str) { return '"' + String(str == null ? '' : str).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"'; } _invalidateCommandCache() { if (this.security) this.security.invalidateCommandCache(); } _pushPrintCapture(onChunk) { // onChunk(buffer) called at pop with the full captured text if (!this._printCaptureStack) this._printCaptureStack = []; this._printCaptureStack.push({ output: '', onChunk }); } _popPrintCapture() { const cap = this._printCaptureStack && this._printCaptureStack.pop(); if (cap && cap.onChunk) cap.onChunk(cap.output); } stopFollow(msg) { if (!this._activeFollow) return false; clearInterval(this._activeFollow.interval); const f = this._activeFollow.file; this._activeFollow = null; if (msg) this.print(msg, 'warning'); else this.print('^C — stopped following ' + f, 'warning'); return true; } initEventListeners() { this.inputField.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) { if (this._autocompletePrefix) { this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion]; } else if (!this._isCdAutocomplete) { this.inputField.value = this.suggestions[this.selectedSuggestion]; } } const cmd = this.inputField.value; // Dedup: don't push if same as last entry if (this.history.length === 0 || this.history[this.history.length - 1] !== cmd) { this.history.push(cmd); } // Trim to max 200 entries if (this.history.length > this._historyCap) { this.history = this.history.slice(-this._historyCap); } this.historyIndex = this.history.length; this._saveCommandHistory(); this.executeCommand(cmd); this.inputField.value = ''; this.inputField.style.height = 'auto'; this.hideAutocomplete(); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (this.historyIndex > 0) { this.historyIndex--; this.inputField.value = this.history[this.historyIndex]; } this.hideAutocomplete(); } else if (e.key === 'ArrowDown') { e.preventDefault(); if (this.historyIndex < this.history.length - 1) { this.historyIndex++; this.inputField.value = this.history[this.historyIndex]; } else { this.historyIndex = this.history.length; this.inputField.value = ''; } this.hideAutocomplete(); } else if (e.key === 'Tab') { e.preventDefault(); if (this.suggestions.length > 0) { if (e.shiftKey) { this.selectedSuggestion = this.selectedSuggestion <= 0 ? this.suggestions.length - 1 : this.selectedSuggestion - 1; } else { this.selectedSuggestion = (this.selectedSuggestion + 1) % this.suggestions.length; } this.updateAutocompleteDisplay(); } } else if (e.ctrlKey && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); this.toggleCmdCards(); } else if (e.ctrlKey && (e.key === 'l' || e.key === 'L')) { // Ctrl+L — clear output (standard terminal) e.preventDefault(); this.clearOutput(); } else if (e.ctrlKey && (e.key === 'c' || e.key === 'C')) { // Ctrl+C — stop tail -f if active, else cancel current input line (standard terminal) e.preventDefault(); if (this.stopFollow()) return; if (this.inputField.value) { this.print('^C', 'warning'); this.inputField.value = ''; this.inputField.style.height = 'auto'; this.hideAutocomplete(); } return; } else if (e.ctrlKey && (e.key === 'u' || e.key === 'U')) { // Ctrl+U — clear line from cursor to start (standard terminal) e.preventDefault(); this.inputField.value = ''; this.inputField.style.height = 'auto'; } else if (e.key === 'Escape') { if (this._aiBusy) { this.executeCommand('ai-stop'); } else if (this.stopFollow()) { // tail -f stopped } else { this.hideAutocomplete(); } } }); this.inputField.addEventListener('input', () => { this.inputField.style.height = 'auto'; this.inputField.style.height = Math.min(this.inputField.scrollHeight, 120) + 'px'; this.showAutocomplete(); }); this.inputField.addEventListener('focus', () => { this.showAutocomplete(); }); document.addEventListener('click', (e) => { if (!e.target.closest('#input-area')) { this.hideAutocomplete(); } }); // 2026-10-22: dashboard.html wrote this key on ⟳ Refresh and nothing listened. window.addEventListener('storage', function (ev) { if (ev.key !== 'webx11_dashboard_data_request') return; const aiMod = this.moduleManager && this.moduleManager.modules ? this.moduleManager.modules.WebX11AIAssistantModule : null; if (aiMod && typeof aiMod._collectDashboardData === 'function') { aiMod._collectDashboardData(); } }.bind(this)); window.addEventListener('online', () => this.updateNetworkStatus()); window.addEventListener('offline', () => this.updateNetworkStatus()); document.getElementById('file-upload-input').addEventListener('change', (e) => { const file = e.target.files[0]; if (file) { const reader = new FileReader(); reader.onload = (evt) => { this.fs.writeFile(file.name, evt.target.result); this.markDirty(); this.print(`File '${file.name}' uploaded successfully.`, 'success'); }; reader.readAsText(file); } e.target.value = ''; }); // Voice mic button // Sound toggle button const soundBtn = document.getElementById('sound-toggle-btn'); if (soundBtn) { soundBtn.addEventListener('click', () => { const current = localStorage.getItem('webx11_config_ai_sound'); if (current === 'off') { localStorage.setItem('webx11_config_ai_sound', 'on'); soundBtn.textContent = '🔊 Sound'; } else { localStorage.setItem('webx11_config_ai_sound', 'off'); soundBtn.textContent = '🔇 Muted'; } }); } // Mic toggle button const micToggleBtn = document.getElementById('voice-toggle-btn'); if (micToggleBtn) { micToggleBtn.addEventListener('click', () => this.toggleMicVisibility()); } } updateNetworkStatus() { // navigator.onLine is a hint, not a fact: an Android WebView reports false while // fully connected (the same lie `status` used to print). Never turn that hint into // "❌ Offline" — claim Online only when the browser says so, otherwise say unknown // and point at `doctor`, which probes for real. const isOnline = navigator.onLine; this.networkStatus.textContent = isOnline ? '📶 Online' : '📶 Network?'; this.networkStatus.title = isOnline ? '' : 'Browser reports offline — run doctor for a live probe'; this.networkStatus.style.color = isOnline ? 'var(--text-color)' : 'var(--text-muted)'; } updatePrompt() { const path = '/' + this.fs.currentPath.join('/'); const label = document.querySelector('.prompt-label'); if (!label) return; if (localStorage.getItem('webx11_smart_prompt') === 'off') { label.textContent = path === '/' ? '$' : path + ' $'; return; } const indicators = []; try { const p = localStorage.getItem('webx11_user_profile'); if (p) { const pf = JSON.parse(p); if (pf.knownFacts && pf.knownFacts.name) indicators.push('👤' + pf.knownFacts.name); } } catch (e) {} try { const idx = this.fs.fsReadJSON('/skills/index.json'); if (idx) { const c = Object.keys(idx).length; if (c > 0) indicators.push('📚' + c); } } catch (e) {} try { const memIdx = this.fs.fsReadJSON('/memory/index.json'); if (memIdx) { const c = Object.keys(memIdx).length; if (c > 0) indicators.push('🧠' + c); } } catch (e) {} try { if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) { indicators.push('💾' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + 'MB'); } } catch (e) {} try { const md = this.fs.readDir('_mods'); if (md && md.content) { const e = Object.keys(md.content).filter(k => k.endsWith('.meta.json')); if (e.length > 0) indicators.push('⚡' + e.length); } } catch (e) {} const prefix = indicators.length ? indicators.join(' ') + ' ' : ''; label.textContent = prefix + (path === '/' ? '$' : path + ' $'); } print(text, type = '') { // Print-capture stack: while active, output goes to the innermost capture // buffer instead of the DOM. Reentrant — parallel/nested captures each // collect their own output (race-safe, replaces monkey-patched print). if (this._printCaptureStack && this._printCaptureStack.length > 0) { const cap = this._printCaptureStack[this._printCaptureStack.length - 1]; cap.output += text + '\n'; return; } if (['error','warning','success','info','text-muted'].includes(type) && !this._showSystemMessages && !type.startsWith('command')) { // Still log to history buffer so it can be shown again when toggled on, // but don't create DOM nodes while system messages are hidden. this._historyBuffer.push({ l: text, t: type }); if (this._historyBuffer.length > this._historyMaxLines) this._historyBuffer.shift(); return; } const line = document.createElement('div'); // Use card layout for typed messages (error/warning/info/success/text-muted), plain for bare text const isTyped = ['error','warning','success'].includes(type); if (isTyped) { line.className = 'output-line msg-card system ' + type; const safeText = String(text).replace(//g, ">"); line.innerHTML = '
${runCmd}?
`;
area.classList.add('visible');
const allowBtn = document.getElementById('cmd-confirm-allow');
const denyBtn = document.getElementById('cmd-confirm-deny');
const onAllow = () => { area.classList.remove('visible'); area.innerHTML = ''; this.executeCommand(runCmd); };
const onDeny = () => { area.classList.remove('visible'); area.innerHTML = ''; this.print('Dismissed.', 'text-muted'); };
allowBtn.addEventListener('click', onAllow);
denyBtn.addEventListener('click', onDeny);
allowBtn.__allow = onAllow;
denyBtn.__deny = onDeny;
}
showConfirmationBar(desc) {
const area = document.getElementById('ai-confirm-area');
if (!area) {
this.print('Confirmation UI unavailable — command blocked.', 'error');
return;
}
this._pendingCommandDesc = desc;
area.innerHTML = `
⚠️ ${desc}
Type: cmd-allow or cmd-deny
`;
area.classList.add('visible');
const allowBtn = document.getElementById('cmd-confirm-allow');
const denyBtn = document.getElementById('cmd-confirm-deny');
const onAllow = () => {
this._resolveCmdConfirm(true);
};
const onDeny = () => {
this._resolveCmdConfirm(false);
};
allowBtn.addEventListener('click', onAllow);
denyBtn.addEventListener('click', onDeny);
allowBtn.__allow = onAllow;
denyBtn.__deny = onDeny;
}
_resolveCmdConfirm(approved) {
const area = document.getElementById('ai-confirm-area');
if (area) {
area.classList.remove('visible');
area.innerHTML = '';
}
if (approved) {
this.print(`Allowed: ${this._pendingCommandDesc || 'command'}`, 'success');
if (this._pendingCommand) {
const fn = this._pendingCommand;
this._pendingCommand = null;
this._pendingCommandDesc = null;
fn();
}
} else {
this.print(`Denied: ${this._pendingCommandDesc || 'command'}`, 'warning');
this._pendingCommand = null;
this._pendingCommandDesc = null;
}
}
_loadHistory() {
try {
const saved = localStorage.getItem('webx11_terminal_output');
if (!saved) return;
const entries = JSON.parse(saved);
if (!Array.isArray(entries)) return;
this._historyBuffer = entries.slice(-this._historyMaxLines);
// Check localStorage quota warning
let quotaWarned = false;
try {
const testKey = '_quota_test_' + Date.now();
localStorage.setItem(testKey, 'x'.repeat(1024));
localStorage.removeItem(testKey);
} catch (e) {
quotaWarned = true;
}
// Lazy-load history: render first 20 immediately, batch the rest
const batchSize = 20;
const toRender = entries.slice(-this._historyMaxLines);
const initial = toRender.slice(0, batchSize);
for (const entry of initial) {
const line = document.createElement('div');
line.className = `output-line ${entry.t || ''}`;
line.textContent = entry.l || '';
this.outputContainer.appendChild(line);
}
if (toRender.length > batchSize) {
const remaining = toRender.slice(batchSize);
const MAX_BATCHES = 50;
const doBatch = (index, batchCount) => {
if (index >= remaining.length || batchCount >= MAX_BATCHES) {
this.scrollToBottom();
return;
}
requestAnimationFrame(() => {
const end = Math.min(index + batchSize, remaining.length);
for (let i = index; i < end; i++) {
const line = document.createElement('div');
line.className = `output-line ${remaining[i].t || ''}`;
line.textContent = remaining[i].l || '';
this.outputContainer.appendChild(line);
}
doBatch(end, (batchCount || 0) + 1);
});
};
doBatch(0, 0);
} else {
this.scrollToBottom();
}
if (quotaWarned) {
const warnLine = document.createElement('div');
warnLine.className = 'output-line warning';
warnLine.textContent = '⚠ localStorage is near quota — history may be incomplete';
this.outputContainer.appendChild(warnLine);
}
} catch (e) {
// Silently ignore corrupt history data
}
}
_saveHistory() {
if (this._historySaveTimer) clearTimeout(this._historySaveTimer);
this._historySaveTimer = setTimeout(() => {
this._historySaveTimer = null;
try {
const slice = this._historyBuffer.slice(-this._historyMaxLines);
localStorage.setItem('webx11_terminal_output', JSON.stringify(slice));
} catch (e) {
// Silently ignore storage errors (e.g. quota exceeded)
}
}, 3000);
}
_saveCommandHistory() {
try {
localStorage.setItem('webx11_command_history', JSON.stringify(this.history));
} catch (e) {
// Silently ignore storage errors
}
}
_trimOutput() {
const container = this.outputContainer;
while (container.children.length > this.MAX_OUTPUT_LINES) {
container.removeChild(container.firstChild);
}
}
toggleExpand() {
const container = document.getElementById('terminal-container');
if (!container) return;
container.classList.toggle('terminal-expanded');
const on = container.classList.contains('terminal-expanded');
this.print(on ? 'Terminal expanded' : 'Terminal compact', 'text-muted');
}
toggleSystemMessages(arg) {
let desired;
if (arg === 'on') desired = true;
else if (arg === 'off') desired = false;
else desired = !this._showSystemMessages;
this._showSystemMessages = desired;
this._saveTerminalConfig();
this.print('System messages ' + (desired ? 'on' : 'off'), 'text-muted');
}
clearOutput() {
if (this.outputContainer) {
this.outputContainer.innerHTML = '';
}
this._historyBuffer = [];
if (this._historySaveTimer) {
clearTimeout(this._historySaveTimer);
this._historySaveTimer = null;
}
try { localStorage.removeItem('webx11_terminal_output'); } catch (e) {}
}
markDirty() {
this._dirty = true;
}
_initAutoSave() {
if (this._autoSaveTimer) return;
this._autoSaveTimer = setInterval(() => {
// Auto-trim on memory pressure
try {
if (performance.memory && performance.memory.usedJSHeapSize > 80 * 1048576) {
const oldMax = this.MAX_OUTPUT_LINES;
this.MAX_OUTPUT_LINES = performance.memory.usedJSHeapSize > 120 * 1048576 ? 50 : 100;
this._trimOutput();
this.MAX_OUTPUT_LINES = oldMax;
this.print('🧹 Terminal trimmed (memory: ' + (performance.memory.usedJSHeapSize / 1048576).toFixed(0) + ' MB)', 'text-muted');
}
} catch (e) {}
if (!this._dirty) return;
this._autoSaveSession();
this._dirty = false;
}, 180000);
window.addEventListener('beforeunload', () => {
// Flush session before tab closes — avoids losing up to 3min of output on crash/close
try { this._autoSaveSession(); } catch (e) {}
if (this._autoSaveTimer) {
clearInterval(this._autoSaveTimer);
this._autoSaveTimer = null;
}
});
}
_autoSaveSession() {
try {
this.fs.mkdirRecursive('_sessions');
const snapshot = {
type: 'session',
version: 2,
savedAt: new Date().toISOString(),
currentPath: this.fs.currentPath,
terminalLines: this._historyBuffer.slice(-50)
};
const snapshotJson = JSON.stringify(snapshot);
this.fs.writeFile('_sessions/_auto.json', snapshotJson);
try {
const ctx = localStorage.getItem('webx11_ai_context');
if (ctx) this.fs.writeFile('_sessions/_auto-context.json', ctx);
} catch (e) {}
} catch (e) {
// Silently handle auto-save failures
}
}
_autoRestoreSession() {
try {
const lastReset = parseInt(localStorage.getItem('webx11_last_reset') || '0', 10);
this.fs.mkdirRecursive('_sessions');
const autoSession = this.fs.readFile('_sessions/_auto.json');
if (autoSession) {
const data = JSON.parse(autoSession);
const savedAt = data.savedAt || 0;
// v4.5.47: skip restoring backups created before the last reset
if (lastReset && savedAt && savedAt < lastReset) {
this.fs.deleteFile('_sessions/_auto.json');
this.fs.deleteFile('_sessions/_auto-context.json');
this.print('↻ Skipped stale auto-session backup (older than last reset)', 'info');
return;
}
if (data.version === 2) {
// v2: metadata-only restore (VFS is persisted independently in localStorage)
this.fs.currentPath = data.currentPath || [];
this.fs.saveToStorage();
this.updatePrompt();
this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success');
// Optionally re-render last few terminal lines
if (data.terminalLines && Array.isArray(data.terminalLines)) {
data.terminalLines.slice(-10).forEach(line => {
const text = typeof line === 'string' ? line : (line.l || '');
this.print(text, typeof line === 'object' ? (line.t || '') : '');
});
}
try {
const aiCtx = this.fs.readFile('_sessions/_auto-context.json');
if (aiCtx) {
localStorage.setItem('webx11_ai_context', aiCtx);
}
} catch (e) {}
this.fs.deleteFile('_sessions/_auto.json');
this.fs.deleteFile('_sessions/_auto-context.json');
} else if (data && data.fsRoot) {
// v1: full VFS restore (legacy)
this.fs.root = data.fsRoot;
this.fs.currentPath = data.currentPath || [];
this.fs.saveToStorage();
this.updatePrompt();
this.print('↻ Auto-restored session from ' + (data.savedAt ? new Date(data.savedAt).toLocaleTimeString() : 'recent'), 'success');
try {
const aiCtx = this.fs.readFile('_sessions/_auto-context.json');
if (aiCtx) {
localStorage.setItem('webx11_ai_context', aiCtx);
}
} catch (e) {}
this.fs.deleteFile('_sessions/_auto.json');
this.fs.deleteFile('_sessions/_auto-context.json');
}
}
} catch (e) {
// Silent on first boot — no auto-session yet
}
}
scrollToBottom() {
this.outputContainer.scrollTop = this.outputContainer.scrollHeight;
}
_getPathSuggestions(partial, type) {
// type: 'dir' = dirs only, 'file' = files + dirs (dirs get trailing /), 'session' = _sessions/*.json
if (type === 'session') {
this.fs.mkdirRecursive('_sessions');
const all = this.fs.ls('_sessions');
const names = all
.filter(e => e.type === 'file' && e.name.endsWith('.json'))
.map(e => e.name.replace('.json', ''));
return partial
? names.filter(n => n.startsWith(partial)).map(n => n)
: names;
}
// Resolve the partial path to determine which directory to list
let dirPath = '';
let filterPrefix = '';
let entries = [];
let prefix = '';
if (partial.includes('/')) {
// "dir1/dir2" or "dir1/dir2/" or "dir1/"
const lastSlash = partial.lastIndexOf('/');
const beforeSlash = partial.slice(0, lastSlash); // dir1
filterPrefix = partial.slice(lastSlash + 1); // dir2 (or '' if ends with /)
// Resolve "dir1" relative to current path
const resolvedDir = this.fs._resolveDir(beforeSlash || '.');
if (!resolvedDir) return [];
entries = Object.keys(resolvedDir.content).map(name => ({
name,
type: resolvedDir.content[name].type,
size: resolvedDir.content[name].content ? resolvedDir.content[name].content.length : 0
}));
prefix = beforeSlash + '/';
} else {
// No slash — list current dir
entries = this.fs.ls();
filterPrefix = partial;
prefix = '';
}
if (type === 'dir') {
return entries
.filter(e => e.type === 'dir' && e.name.startsWith(filterPrefix))
.map(e => prefix + e.name + '/');
}
// 'file' — show files + dirs (dirs with trailing /)
return entries
.filter(e => e.name.startsWith(filterPrefix))
.map(e => e.type === 'dir' ? prefix + e.name + '/' : prefix + e.name);
}
// ── Enum autocomplete from commandsMeta ──
_tryEnumAutocomplete(cmdName) {
this.suggestions = [];
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
const meta = mod.commandsMeta && mod.commandsMeta[cmdName];
if (meta && meta.argType === 'enum' && meta.enumFn && typeof mod[meta.enumFn] === 'function') {
const values = mod[meta.enumFn]();
if (Array.isArray(values) && values.length > 0) {
this.suggestions.push(...values);
return true;
}
}
}
return false;
}
showAutocomplete() {
if (!this.moduleManager || !this.security) return;
const input = this.inputField.value;
// Always clear dropdown innerHTML at the start — prevents stale state from prior input
this.dropdown.innerHTML = '';
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
const trimmed = input.trim();
const commands = this.moduleManager.getAllCommands() || {};
// Merge built-in internals into autocomplete (they're handled via if-chain, not module commands)
for (const c of this.security.commandMeta.keys()) {
if (!(c in commands)) commands[c] = 'builtin';
}
if (input.trim().length === 0) {
this.hideAutocomplete();
return;
}
const parts = input.split(' ');
const cmdPart = parts[0].toLowerCase();
const lowerInput = input.toLowerCase();
// Expanded command lists for autocomplete (fallback when commandsMeta lacks argType)
// NOTE: Commands with argType:'enum' in their commandsMeta are handled by _tryEnumAutocomplete
// and should NOT be in these hardcoded lists (the enum check is called BEFORE these).
const fileArgCommands = ['cat', 'preview', 'rm', 'rmdir', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'trash', 'ls-trash', 'upload', 'vfs-export', 'vfs-import', 'files', 'preview', 'skill-view', 'skill-create', 'skill-patch', 'skill-delete', 'skill-sync', 'webdav-ls', 'webdav-push', 'webdav-pull', 'webdav-rm', 'webdav-mkdir', 'webdav-rename', 'webdav-move', 'webdav-config', 'nvfs-index', 'nvfs-resolve', 'nvfs-tree'];
const dirArgCommands = ['ls', 'cd', 'mkdir', 'rmdir', 'upload', 'webdav-ls', 'webdav-mkdir', 'vfs-export', 'vfs-import', 'skill-create'];
const sessionArgCommands = ['session-load', 'session-delete', 'session-export', 'session-save', 'session-list', 'session-import', 'session-new'];
const pathArgCommands = ['cat', 'preview', 'rm', 'edit', 'view', 'cp', 'mv', 'touch', 'put', 'run', 'chmod', 'gzip', 'gunzip', 'encrypt', 'decrypt', 'download', 'webdav-backup', 'webdav-restore', 'webdav-upload', 'webdav-download', 'webdav-list', 'ls', 'cd', 'mkdir', 'rmdir', 'cat', 'preview'];
// Collect commands that take arguments from ext module metadata
const extFileCmds = new Set();
const extDirCmds = new Set();
for (const modName in this.moduleManager.modules) {
const mod = this.moduleManager.modules[modName];
if (mod.commandsMeta) {
for (const [c, meta] of Object.entries(mod.commandsMeta)) {
if (meta.argType === 'file' || meta.argType === 'path') extFileCmds.add(c);
if (meta.argType === 'dir') extDirCmds.add(c);
}
}
}
// If we have more than one word, we're past the command name — autocomplete file/dir/session names
if (parts.length > 1 && parts[1].length > 0) {
const partial = parts.slice(1).join(' ');
const partialTrimmed = partial.trim();
if (partialTrimmed === '..') {
this.hideAutocomplete();
return;
}
if (this._tryEnumAutocomplete(cmdPart)) {
this.suggestions = this.suggestions.filter(s => s.startsWith(partialTrimmed));
} else if (sessionArgCommands.includes(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'session');
} else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'dir');
this._isCdAutocomplete = true;
} else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions(partialTrimmed, 'file');
} else {
// Fallback: show files anyway
this.suggestions = this._getPathSuggestions(partialTrimmed, 'file');
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Just typed a space after command — show all matching entries
if (parts.length > 1) {
if (this._tryEnumAutocomplete(cmdPart)) {
// suggestions already populated
} else if (sessionArgCommands.includes(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'session');
} else if (dirArgCommands.includes(cmdPart) || extDirCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'dir');
this._isCdAutocomplete = true;
} else if (fileArgCommands.includes(cmdPart) || extFileCmds.has(cmdPart)) {
this.suggestions = this._getPathSuggestions('', 'file');
} else {
// Fallback: show files
this.suggestions = this._getPathSuggestions('', 'file');
}
this._autocompletePrefix = cmdPart + ' ';
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Directory autocomplete for bare 'cd ' (no partial yet)
if (lowerInput.startsWith('cd ')) {
this.suggestions = this._getPathSuggestions('', 'dir');
if (this.suggestions.length > 0) {
this._isCdAutocomplete = true;
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
return;
}
// Command name autocomplete (first word only) — also clears any stale prefix
this._autocompletePrefix = '';
for (const cmd in commands) {
if (cmd.startsWith(cmdPart) && cmd !== cmdPart) {
this.suggestions.push(cmd);
}
}
if (this.suggestions.length > 0) {
this.renderAutocomplete();
} else {
this.hideAutocomplete();
}
}
renderAutocomplete() {
this.dropdown.innerHTML = '';
this.suggestions.forEach((item, index) => {
const div = document.createElement('div');
div.className = `autocomplete-item ${index === this.selectedSuggestion ? 'selected' : ''}`;
div.innerHTML = this._isCdAutocomplete
? `${item}`
: `${item}`;
div.onclick = () => {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + item;
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + item;
} else {
this.inputField.value = item + ' ';
}
this.hideAutocomplete();
this.inputField.focus();
};
this.dropdown.appendChild(div);
});
this.dropdown.style.display = 'block';
}
updateAutocompleteDisplay() {
const items = this.dropdown.querySelectorAll('.autocomplete-item');
items.forEach((item, index) => {
item.classList.toggle('selected', index === this.selectedSuggestion);
});
if (this.selectedSuggestion >= 0 && this.suggestions[this.selectedSuggestion]) {
if (this._autocompletePrefix) {
this.inputField.value = this._autocompletePrefix + this.suggestions[this.selectedSuggestion];
} else if (this._isCdAutocomplete) {
this.inputField.value = 'cd ' + this.suggestions[this.selectedSuggestion];
} else {
this.inputField.value = this.suggestions[this.selectedSuggestion] + ' ';
}
}
}
hideAutocomplete() {
this.dropdown.style.display = 'none';
this.suggestions = [];
this.selectedSuggestion = -1;
this._isCdAutocomplete = false;
this._autocompletePrefix = '';
}
loadBuiltInModules() {
this._safeRegister('SystemModule', 'system');
this._safeRegister('FileSystemModule', 'filesystem');
this._safeRegister('ModuleManagerModule', 'module manager');
this._safeRegister('OllamaModule', 'ollama');
this._safeRegister('BillingModule', 'billing');
this._safeRegister('SearXNGModule', 'searxng search');
this._safeRegister('ConfigManagerModule', 'config');
this._safeRegister('WebX11AIAssistantModule', 'AI assistant');
this._safeRegister('WebFetcherModule', 'web fetcher');
this._safeRegister('WebXAIAliasModule', 'AI alias');
this._safeRegister('SecretVault', 'secret vault');
this.reloadPersistedModules();
this._safeRegister('ChatStudioModule', 'chat studio');
this._safeRegister('AgentRegistryModule', 'agent registry');
this._safeRegister('LeaseModule', 'lease system');
this._safeRegister('OrchestratorModule', 'orchestrator');
this._safeRegister('NeuralVFSModule', 'neural VFS');
this._safeRegister('SandboxModule', 'sandbox');
this._safeRegister('MemoryGraphModule', 'memory graph');
this._safeRegister('PredictorModule', 'predictor');
this._safeRegister('WorldModelModule', 'world model');
this._safeRegister('ServerMonitorModule', 'server monitor');
this._safeRegister('SmartContextModule', 'smart context');
this._safeRegister('SimpleGUIModule', 'simple GUI builder');
this._safeRegister('DebugSupervisorModule', 'debug supervisor');
// 2026-11-12 — UI shell re-enabled BEHIND AN OPT-IN FLAG (plan Task 9, v4.6.162).
// v4.6.157 had disabled it outright after crash reports; the crash itself was never
// reproduced (F2) and the two defects the investigation did find (F3 413, F4 arg
// coercion) were fixed instead. So the six modes come back, but only for a visitor
// who asks for them: `?ui=1` on the URL or localStorage['webx11_ui_experimental']=1.
// Without the flag nothing changes: the shell is not registered, no mode can mount
// (the ui-*.js files begin with `if (!terminal.uiShell) return;`), no first-run
// picker appears, and `ui` does not exist. Guard: admin/scripts/verify-newos4-ui-gate.py
// (cron ui-gate-guard) fails if the gate is lost or the bare call returns.
const _uiExp = (function () {
try {
// ANY `?ui=