/** * @fileoverview App settings modal, visibility settings (header/panel/device-specific defaults), * web push notifications, session lifecycle log (JSONL viewer), tunnel/QR management, * persistent parent associations, and help modal. * Includes 13 SSE handlers for hooks and tunnel events. * * @mixin Extends CodemanApp.prototype via Object.assign * @dependency app.js (CodemanApp class, this.notificationManager, this._tunnelUrl) * @dependency constants.js (escapeHtml) * @dependency keyboard-accessory.js (FocusTrap) * @loadorder 10 of 15 — loaded after ralph-panel.js, before panels-ui.js */ Object.assign(CodemanApp.prototype, { // Hooks (Claude Code hook events) _onHookIdlePrompt(data) { // A prompt the server opened ALREADY acknowledged raises no alert here. Today that // means the session is watching work it started itself (`acknowledgedReason` reads // "watching 1 monitor"), so the pane is quiet because the agent is waiting for its // own monitor, not for you. The item still exists and still shows in the drawer; // only the tab alert and the desktop notification are declined. A page that reloads // instead of receiving this event reaches the same conclusion from `acknowledgedAt` // in seedApprovals (approvals-ui.js). if (data.acknowledgedReason) return; // Always track pending hook - alert will show when switching away from session if (data.sessionId) { this.setPendingHook(data.sessionId, 'idle_prompt'); } this._notifySession(data.sessionId, 'warning', 'hook-idle', 'Waiting for Input', data.message || 'Claude is idle and waiting for a prompt'); }, _onHookPermissionPrompt(data) { // Always track pending hook - action alerts need user interaction to clear if (data.sessionId) { this.setPendingHook(data.sessionId, 'permission_prompt'); } const toolInfo = data.tool ? `${data.tool}${data.command ? ': ' + data.command : data.file ? ': ' + data.file : ''}` : ''; this._notifySession(data.sessionId, 'critical', 'hook-permission', 'Permission Required', toolInfo || 'Claude needs tool approval to continue'); }, _onHookElicitationDialog(data) { // Always track pending hook - action alerts need user interaction to clear if (data.sessionId) { this.setPendingHook(data.sessionId, 'elicitation_dialog'); } this._notifySession(data.sessionId, 'critical', 'hook-elicitation', 'Question Asked', data.question || 'Claude is asking a question and waiting for your answer'); }, _onHookElicitationComplete(data) { // Question answered in the terminal: clear the action alert without // waiting for `stop` (the turn may keep running for a long time). // ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS, // which resolves a session's pending item whatever its kind. An // AskUserQuestion dialog arrives as `permission_prompt` (only MCP // elicitation is `elicitation_dialog`), so clearing just the elicitation // entry left the red alert armed on exactly the dialog these events are // most often about. Normally the server's `approval:resolved` broadcast // clears it too; this is the path that still works when the store holds no // item for the session (restart, superseded). if (data.sessionId) { this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); this.clearPendingHooks(data.sessionId, 'permission_prompt'); } }, _onHookElicitationResponse(data) { this._onHookElicitationComplete(data); }, _onHookStop(data) { // Clear all pending hooks when Claude finishes responding if (data.sessionId) { this.clearPendingHooks(data.sessionId); } this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding'); }, _onHookAgentWorking(data) { // The agent started a turn, so whatever it was blocked on is gone. Reported // by the DeepSeek status bridge; a harness turn cannot run while one of its // own modal approvals is on screen, so this means the dialog was answered in // the terminal. Same clearing as _onHookElicitationComplete, and notably NOT // a notification: a turn STARTING is not news. if (data.sessionId) { this.clearPendingHooks(data.sessionId, 'elicitation_dialog'); this.clearPendingHooks(data.sessionId, 'permission_prompt'); this.clearPendingHooks(data.sessionId, 'idle_prompt'); } }, _onHookTeammateIdle(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`); }, _onHookTaskCompleted(data) { const session = this.sessions.get(data.sessionId); this._notifySession(data.sessionId, 'info', 'hook-task-completed', 'Task Completed', `A team task completed in ${session?.name || data.sessionId}`); }, // Tunnel _onTunnelStarted(data) { console.log('[Tunnel] Started:', data.url); this._tunnelUrl = data.url; this._dismissTunnelConnecting(); this._updateTunnelUrlDisplay(data.url); this._updateTunnelIndicator(true); const welcomeVisible = document.getElementById('welcomeOverlay')?.classList.contains('visible'); if (welcomeVisible) { // On welcome screen: QR appears inline, expanded first this._updateWelcomeTunnelBtn(true, data.url, true); this.showToast(`Tunnel active`, 'success'); } else { // Not on welcome screen: popup QR overlay this._updateWelcomeTunnelBtn(true, data.url); this.showToast(`Tunnel active: ${data.url}`, 'success'); this.showTunnelQR(); } }, _onTunnelStopped() { console.log('[Tunnel] Stopped'); this._tunnelUrl = null; this._dismissTunnelConnecting(); this._updateTunnelUrlDisplay(null); this._updateWelcomeTunnelBtn(false); this._updateTunnelIndicator(false); this.closeTunnelPanel(); this.closeTunnelQR(); }, _onTunnelProgress(data) { console.log('[Tunnel] Progress:', data.message); const toast = document.getElementById('tunnelConnectingToast'); if (toast) { toast.innerHTML = ` ${data.message}`; } // Also update button text if on welcome screen const btn = document.getElementById('welcomeTunnelBtn'); if (btn?.classList.contains('connecting')) { btn.innerHTML = ` ${data.message}`; } }, _onTunnelError(data) { console.warn('[Tunnel] Error:', data.message); this._dismissTunnelConnecting(); this.showToast(`Tunnel error: ${data.message}`, 'error'); const btn = document.getElementById('welcomeTunnelBtn'); if (btn) { btn.disabled = false; btn.classList.remove('connecting'); } }, _onTunnelQrRotated(data) { if (data.svg) { const container = document.getElementById('tunnelQrContainer'); if (container) container.innerHTML = data.svg; const welcomeInner = document.getElementById('welcomeQrInner'); if (welcomeInner) welcomeInner.innerHTML = data.svg; } else { this._refreshTunnelQrFromApi(); } this._resetQrCountdown(); }, _onTunnelQrRegenerated(data) { if (data.svg) { const container = document.getElementById('tunnelQrContainer'); if (container) container.innerHTML = data.svg; const welcomeInner = document.getElementById('welcomeQrInner'); if (welcomeInner) welcomeInner.innerHTML = data.svg; } else { this._refreshTunnelQrFromApi(); } this._resetQrCountdown(); }, _onTunnelQrAuthUsed(data) { const ua = data.ua || 'Unknown device'; const family = ua.match(/Chrome|Firefox|Safari|Edge|Mobile/)?.[0] || 'Browser'; this.showToast(`Device authenticated via QR (${family}, ${data.ip}). Not you?`, 'warning', { duration: 10000, action: { label: 'Revoke All', onClick: () => { fetch('/api/auth/revoke', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) .then(() => this.showToast('All sessions revoked', 'success')) .catch(() => this.showToast('Failed to revoke sessions', 'error')); }}, }); }, // ═══════════════════════════════════════════════════════════════ // Web Push // ═══════════════════════════════════════════════════════════════ registerServiceWorker() { if (!('serviceWorker' in navigator)) return; // Behind a sub-path mount the worker is served at /sw.js and controls // / (Service-Worker-Allowed is '/', so this narrower scope is permitted). const _swBase = window.CodemanBase?.base || ''; navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => { this._swRegistration = reg; // Listen for messages from service worker (notification clicks) navigator.serviceWorker.addEventListener('message', (event) => { if (event.data?.type === 'notification-click') { const { sessionId, action, approvalId } = event.data; if (action) { // Approve/Deny action buttons on a push: answer via the // Approvals Inbox instead of just focusing the session. this.handleNotificationAction?.(action, approvalId, sessionId); } else if (sessionId && this.sessions.has(sessionId)) { this.selectSession(sessionId); } window.focus(); } }); // Check if already subscribed reg.pushManager.getSubscription().then((sub) => { if (sub) { this._pushSubscription = sub; this._updatePushUI(true); } }); }).catch(() => { // Service worker registration failed (likely not HTTPS) }); }, async subscribeToPush() { if (!this._swRegistration) { this.showToast('Service worker not available. HTTPS or localhost required.', 'error'); return; } try { // Get VAPID public key from server const keyData = await this._apiJson('/api/push/vapid-key'); if (!keyData) throw new Error('Failed to get VAPID key'); const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey); const subscription = await this._swRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); // Send subscription to server const subJson = subscription.toJSON(); const data = await this._apiJson('/api/push/subscribe', { method: 'POST', body: { endpoint: subJson.endpoint, keys: subJson.keys, userAgent: navigator.userAgent, pushPreferences: this._buildPushPreferences(), }, }); if (!data) throw new Error('Failed to register subscription'); this._pushSubscription = subscription; this._pushSubscriptionId = data.id; localStorage.setItem('codeman-push-subscription-id', data.id); this._updatePushUI(true); this.showToast('Push notifications enabled', 'success'); } catch (err) { this.showToast('Push subscription failed: ' + (err.message || err), 'error'); } }, async unsubscribeFromPush() { try { if (this._pushSubscription) { await this._pushSubscription.unsubscribe(); } const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); if (subId) { await fetch(`/api/push/subscribe/${subId}`, { method: 'DELETE' }).catch(() => {}); } this._pushSubscription = null; this._pushSubscriptionId = null; localStorage.removeItem('codeman-push-subscription-id'); this._updatePushUI(false); this.showToast('Push notifications disabled', 'success'); } catch (err) { this.showToast('Failed to unsubscribe: ' + (err.message || err), 'error'); } }, async togglePushSubscription() { if (this._pushSubscription) { await this.unsubscribeFromPush(); } else { await this.subscribeToPush(); } }, /** Sync push preferences to server */ async _syncPushPreferences() { const subId = this._pushSubscriptionId || localStorage.getItem('codeman-push-subscription-id'); if (!subId) return; try { await fetch(`/api/push/subscribe/${subId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pushPreferences: this._buildPushPreferences() }), }); } catch { // Silently fail — prefs saved locally, will sync on next subscribe } }, /** Build push preferences object from current event type checkboxes */ _buildPushPreferences() { const prefs = {}; const eventMap = { 'hook:permission_prompt': 'eventPermissionPush', 'hook:elicitation_dialog': 'eventQuestionPush', 'hook:idle_prompt': 'eventIdlePush', 'hook:stop': 'eventStopPush', 'respawn:blocked': 'eventRespawnPush', 'session:ralphCompletionDetected': 'eventRalphPush', }; for (const [event, checkboxId] of Object.entries(eventMap)) { const el = document.getElementById(checkboxId); prefs[event] = el ? el.checked : true; } // session:error always receives push (no per-event toggle, always critical) prefs['session:error'] = true; return prefs; }, _updatePushUI(subscribed) { const btn = document.getElementById('pushSubscribeBtn'); const status = document.getElementById('pushSubscriptionStatus'); if (btn) btn.textContent = subscribed ? 'Unsubscribe' : 'Subscribe'; if (status) { status.textContent = subscribed ? 'active' : 'off'; status.classList.remove('granted', 'denied'); if (subscribed) status.classList.add('granted'); } }, // ═══════════════════════════════════════════════════════════════ // App Settings Modal // ═══════════════════════════════════════════════════════════════ /** * Point one terminal-weight select at its stored value. * * A stored value the picker does not list (a hand-set 350, or a weight from a * build whose options differ) is ADDED to the select rather than dropped: * otherwise `select.value = '350'` silently selects nothing, the next save * reads back '' and the setting resets itself just for having been opened. * Empty means "use xterm's default for this slot". */ populateTerminalFontWeight(select, value) { if (!select) return; const stored = value === undefined || value === null ? '' : String(value).trim(); if (stored && !Array.from(select.options).some((opt) => opt.value === stored)) { const extra = document.createElement('option'); extra.value = stored; extra.textContent = `${stored} (custom)`; select.appendChild(extra); } select.value = stored; }, /** Read one terminal-weight select back. '' means default; the resolver in constants.js validates. */ readTerminalFontWeight(select) { return select?.value.trim() || ''; }, openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); document.getElementById('appSettingsDisplayName').value = settings.displayName || 'Codeman'; document.getElementById('appSettingsLanguage').value = settings.language === 'zh-CN' ? 'zh-CN' : 'en'; document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; // Use device-aware defaults for display settings (mobile has different defaults) const defaults = this.getDefaultSettings(); document.getElementById('appSettingsRalphEnabled').checked = settings.ralphTrackerEnabled ?? defaults.ralphTrackerEnabled ?? false; // Header visibility settings document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false; document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue'; // Entrance animations. Deliberately NOT part of the settings payload: the // styles persist to their own localStorage keys via setAnimTheme(), which // keeps them per-device without touching the .strict() SettingsUpdateSchema. this._syncEntranceAnimSetting?.(); // WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide // the toggle there so it can't promise something that won't apply). document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true; const webglItem = document.getElementById('appSettingsWebglRendererItem'); if (webglItem) webglItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? defaults.showMonitor ?? false; document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? defaults.showProjectInsights ?? false; document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false; document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false; document.getElementById('appSettingsShowUltracodeAgents').checked = settings.showUltracodeAgents ?? defaults.showUltracodeAgents ?? false; // Approvals Inbox: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsApprovalsInbox').checked = settings.approvalsInboxEnabled === true; // Custom Model Endpoint Profiles: synced, default OFF. The toggle governs both // the Run-menu picker's generated entries and this settings panel's visibility; // the endpoint list itself is server state, loaded on demand below. document.getElementById('appSettingsCustomModelEndpoints').checked = settings.customModelEndpointsEnabled === true; // Assigning .checked above does not fire onchange, so the body's visibility // (and its lazy load) needs an explicit sync on every open, not just a save. this.applyCustomModelEndpointsVisibility(); // CLI management (docs/cli-enable-disable-plan.md): synced, default OFF. document.getElementById('appSettingsCliManagement').checked = settings.cliManagementEnabled === true; // Same reasoning as applyCustomModelEndpointsVisibility above: assigning // .checked fires no onchange, so the list's visibility (and lazy load) // needs an explicit sync on every open, not just a save. this.applyCliManagementVisibility(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true; document.getElementById('appSettingsUltracodeFloatingWindows').checked = settings.ultracodeFloatingWindows ?? defaults.ultracodeFloatingWindows ?? false; document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false; document.getElementById('appSettingsShowSplitButton').checked = settings.showSplitButton ?? defaults.showSplitButton ?? false; document.getElementById('appSettingsShowPlanUsageLimits').checked = this.planUsageChipEnabled(settings); document.getElementById('appSettingsShowRedrawButton').checked = settings.showRedrawButton ?? defaults.showRedrawButton ?? false; // Phone overview home screen: only meaningful under 600px, so the row is // hidden elsewhere rather than offering a toggle that changes nothing. // Spawn lineage lines: desktop-only (the overlay sits UNDER the fixed mobile // header), so the row is hidden elsewhere rather than offering a toggle that // changes nothing. Default ON — only an explicit false turns it off. document.getElementById('appSettingsLineageLines').checked = settings.sessionLineageLines ?? defaults.sessionLineageLines ?? true; // Auto-name sessions: synced, default OFF (opt-in; only an explicit true enables). document.getElementById('appSettingsAutoNameSessions').checked = settings.autoNameSessions === true; const lineageItem = document.getElementById('appSettingsLineageLinesItem'); if (lineageItem) lineageItem.style.display = MobileDetection.getDeviceType() === 'desktop' ? '' : 'none'; document.getElementById('appSettingsMobileOverview').checked = settings.mobileOverviewEnabled ?? defaults.mobileOverviewEnabled ?? false; const mobileOverviewItem = document.getElementById('appSettingsMobileOverviewItem'); if (mobileOverviewItem) mobileOverviewItem.style.display = MobileDetection.getDeviceType() === 'mobile' ? '' : 'none'; // Session Manager, Away Digest and Cron buttons all default OFF (opt-in under // Display → Header Displays; the Cron button also ships with btn-cron--hidden // in the template, so an unchecked box and a hidden button stay consistent). document.getElementById('appSettingsShowSessionButton').checked = settings.showSessionButton ?? defaults.showSessionButton ?? false; document.getElementById('appSettingsShowAwayDigestButton').checked = settings.showAwayDigestButton ?? defaults.showAwayDigestButton ?? false; document.getElementById('appSettingsShowCronButton').checked = settings.showCronButton ?? defaults.showCronButton ?? false; // Gesture control lives in the Input section (alongside Local Echo / CJK Input) // but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets // window.__codemanGestureAvailable). Hide just this item otherwise so the toggle // can't promise something that won't work. const gestureItem = document.getElementById('appSettingsGestureControlItem'); if (gestureItem) gestureItem.style.display = window.__codemanGestureAvailable ? '' : 'none'; document.getElementById('appSettingsGestureControl').checked = settings.gestureControlEnabled ?? defaults.gestureControlEnabled ?? false; document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); // Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly // overwrites the system clipboard on a gesture the user may have meant only as // a way to read, so it is opt-in rather than a default anyone has to discover. document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true; // Default ON, so an absent key reads as enabled rather than as off. document.getElementById('appSettingsCopyStripMargin').checked = settings.copyStripMargin !== false; document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || ''; this.populateTerminalFontWeight(document.getElementById('appSettingsTerminalFontWeight'), settings.terminalFontWeight); this.populateTerminalFontWeight( document.getElementById('appSettingsTerminalFontWeightBold'), settings.terminalFontWeightBold ); document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; document.getElementById('appSettingsTabOrientation').value = settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal'; const tabRailWidth = window.CodemanTabRail?.resolveWidth({ // Same default resolution as applyTabRailWidth(): a rail that has never // been sized shows the width it is actually rendering at, which for // detailed rows is the Wide preset rather than 256. The rich-aware // default must come BEFORE the per-device defaults blob: the handheld // blob carries tabRailWidth: 256, which applyTabRailWidth() never reads, // so consulting it first showed a tablet's unsized rich rail as 256 while // it rendered at 320 — and a routine Save then PERSISTED the 256. width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 256, }) ?? 256; this.syncTabRailWidthSetting?.(tabRailWidth); document.getElementById('appSettingsTabRailDetail').value = settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; document.getElementById('appSettingsTabRailSort').value = settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( settings.sessionSidebarFontSize ?? defaults.sessionSidebarFontSize ); document.getElementById('appSettingsSessionSidebarFontSize').value = String(sessionSidebarFontSize); document.getElementById('appSettingsSessionSidebarFontSizeValue').textContent = `${sessionSidebarFontSize} px`; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); const allowedToolsRow = document.getElementById('allowedToolsRow'); claudeModeSelect.value = settings.claudeMode || 'dangerously-skip-permissions'; document.getElementById('appSettingsAllowedTools').value = settings.allowedTools || ''; allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; // Toggle allowed tools row visibility based on mode selection claudeModeSelect.onchange = () => { allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; }; // Codex CLI settings. The inputs are always populated (and always read back // by saveAppSettings), even when the tab is hidden below, so a user without // codex installed can never silently wipe the codex prefs of an instance // that does have it. document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked = settings.codexDangerouslyBypassApprovals ?? false; document.getElementById('appSettingsCodexAnimations').checked = settings.codexAnimationsEnabled ?? false; this._applyCodexSettingsVisibility(); // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsAgentSkill').checked = settings.agentSkillEnabled ?? false; // Default ON: an absent key is a user who has never seen this setting, and OFF // for them means no tab alerts in any workspace Codeman did not scaffold. document.getElementById('appSettingsWorkspaceHooks').checked = settings.workspaceHooksEnabled !== false; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; document.getElementById('appSettingsOpusContext1m').checked = settings.opusContext1mEnabled ?? false; document.getElementById('appSettingsRemoteAutoReconnect').checked = settings.remoteAutoReconnect ?? true; document.getElementById('appSettingsThinkingEffort').value = settings.thinkingEffort ?? ''; // CPU Priority settings const niceSettings = settings.nice || {}; document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false; document.getElementById('appSettingsNiceValue').value = niceSettings.niceValue ?? 10; // Model configuration (loaded from server) this.loadModelConfigForSettings(); // Custom Model Endpoint Profiles' own load is gated on the toggle above (see // applyCustomModelEndpointsVisibility) — unlike model config, this GET is // pointless work with the feature off, so it is not fired unconditionally. // Notification settings const notifPrefs = this.notificationManager?.preferences || {}; document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; document.getElementById('appSettingsNotifBrowser').checked = notifPrefs.browserNotifications ?? false; document.getElementById('appSettingsNotifAudio').checked = notifPrefs.audioAlerts ?? false; document.getElementById('appSettingsNotifStuckMins').value = Math.round((notifPrefs.stuckThresholdMs || 600000) / 60000); document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical; document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning; document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo; // Push notification settings document.getElementById('appSettingsPushEnabled').checked = !!this._pushSubscription; this._updatePushUI(!!this._pushSubscription); // Per-event-type preferences const eventTypes = notifPrefs.eventTypes || {}; // Permission prompts const permPref = eventTypes.permission_prompt || {}; document.getElementById('eventPermissionEnabled').checked = permPref.enabled ?? true; document.getElementById('eventPermissionBrowser').checked = permPref.browser ?? true; document.getElementById('eventPermissionPush').checked = permPref.push ?? false; document.getElementById('eventPermissionAudio').checked = permPref.audio ?? true; // Questions (elicitation_dialog) const questionPref = eventTypes.elicitation_dialog || {}; document.getElementById('eventQuestionEnabled').checked = questionPref.enabled ?? true; document.getElementById('eventQuestionBrowser').checked = questionPref.browser ?? true; document.getElementById('eventQuestionPush').checked = questionPref.push ?? false; document.getElementById('eventQuestionAudio').checked = questionPref.audio ?? true; // Session idle (idle_prompt) const idlePref = eventTypes.idle_prompt || {}; document.getElementById('eventIdleEnabled').checked = idlePref.enabled ?? true; document.getElementById('eventIdleBrowser').checked = idlePref.browser ?? true; document.getElementById('eventIdlePush').checked = idlePref.push ?? false; document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false; // Response complete (stop) const stopPref = eventTypes.stop || {}; document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false; document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false; document.getElementById('eventStopPush').checked = stopPref.push ?? false; document.getElementById('eventStopAudio').checked = stopPref.audio ?? false; // Respawn cycles const respawnPref = eventTypes.respawn_cycle || {}; document.getElementById('eventRespawnEnabled').checked = respawnPref.enabled ?? true; document.getElementById('eventRespawnBrowser').checked = respawnPref.browser ?? false; document.getElementById('eventRespawnPush').checked = respawnPref.push ?? false; document.getElementById('eventRespawnAudio').checked = respawnPref.audio ?? false; // Task complete (ralph_complete) const ralphPref = eventTypes.ralph_complete || {}; document.getElementById('eventRalphEnabled').checked = ralphPref.enabled ?? true; document.getElementById('eventRalphBrowser').checked = ralphPref.browser ?? true; document.getElementById('eventRalphPush').checked = ralphPref.push ?? false; document.getElementById('eventRalphAudio').checked = ralphPref.audio ?? true; // Subagent activity (subagent_spawn and subagent_complete) const subagentPref = eventTypes.subagent_spawn || {}; document.getElementById('eventSubagentEnabled').checked = subagentPref.enabled ?? false; document.getElementById('eventSubagentBrowser').checked = subagentPref.browser ?? false; document.getElementById('eventSubagentPush').checked = subagentPref.push ?? false; document.getElementById('eventSubagentAudio').checked = subagentPref.audio ?? false; // Update permission status display (compact format for new grid layout) const permStatus = document.getElementById('notifPermissionStatus'); if (permStatus && typeof Notification !== 'undefined') { const perm = Notification.permission; permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?'; permStatus.classList.remove('granted', 'denied'); if (perm === 'granted') permStatus.classList.add('granted'); else if (perm === 'denied') permStatus.classList.add('denied'); } // Voice settings (loaded from localStorage only) const voiceCfg = VoiceInput._getDeepgramConfig(); document.getElementById('voiceDeepgramKey').value = voiceCfg.apiKey || ''; document.getElementById('voiceLanguage').value = voiceCfg.language || 'en-US'; document.getElementById('voiceKeyterms').value = voiceCfg.keyterms || DEFAULT_VOICE_KEYTERMS; document.getElementById('voiceInsertMode').value = voiceCfg.insertMode || 'direct'; document.getElementById('voiceProvider').value = voiceCfg.provider || 'auto'; document.getElementById('appSettingsClaudeVoice').checked = settings.claudeVoiceEnabled ?? false; // Reset key visibility to hidden const keyInput = document.getElementById('voiceDeepgramKey'); keyInput.type = 'password'; document.getElementById('voiceKeyToggleBtn').textContent = 'Show'; // Update provider status. The Claude row needs a fresh server probe: the // setting is synced, so another device may have flipped it since page load. this._renderVoiceProviderStatus(); VoiceInput.refreshClaudeStatus().then(() => this._renderVoiceProviderStatus()); // Updates section — show current version, reset transient result/progress UI. this._initUpdatesSection(); // Model cards + effort segment are views over the hidden 's own options, so the * select stays the single source of truth that openAppSettings/saveAppSettings * read and write by id. The `[1m]` variants are folded away: context width is a * property of the chosen model (the "1M context window" switch), not a rival * setting that silently loses to it. */ _buildModelCards() { const select = document.getElementById('appSettingsClaudeModel'); const grid = document.getElementById('appSettingsModelCards'); if (!select || !grid || grid.dataset.built === '1' || !select.options) return; grid.innerHTML = ''; [...select.options] .filter(opt => opt.dataset.variant !== '1m') .forEach(opt => { const card = document.createElement('button'); card.type = 'button'; card.className = 'set-modelcard'; card.setAttribute('role', 'radio'); card.dataset.value = opt.value; if (opt.dataset.ctx === '1') card.dataset.ctx = '1'; const top = document.createElement('span'); top.className = 'set-mc-top'; const name = document.createElement('span'); name.className = 'set-mc-name'; name.textContent = opt.textContent; top.appendChild(name); const dot = document.createElement('span'); dot.className = 'set-mc-dot'; top.appendChild(dot); card.appendChild(top); const meta = document.createElement('span'); meta.className = 'set-mc-meta'; meta.textContent = opt.dataset.meta || ''; card.appendChild(meta); if (opt.dataset.ctx === '1') { const ctx = document.createElement('span'); ctx.className = 'set-mc-ctx'; ctx.textContent = '1M capable'; card.appendChild(ctx); } card.addEventListener('click', () => { this._settingsModelBase = opt.value; this._applyModelSelection(); }); grid.appendChild(card); }); grid.dataset.built = '1'; }, /** Derive card + context-switch state from the select's stored value. */ _syncModelCards() { const select = document.getElementById('appSettingsClaudeModel'); if (!select) return; const value = select.value || ''; this._settingsModelBase = value.endsWith('[1m]') ? value.slice(0, -4) : value; if (value.endsWith('[1m]')) { const ctx = document.getElementById('appSettingsOpusContext1m'); if (ctx) ctx.checked = true; } this._applyModelSelection(); }, /** Compose card + context switch back into the select's value. */ _applyModelSelection() { const select = document.getElementById('appSettingsClaudeModel'); const grid = document.getElementById('appSettingsModelCards'); if (!select || !grid) return; const base = this._settingsModelBase || ''; let capable = false; grid.querySelectorAll('.set-modelcard').forEach(card => { const on = card.dataset.value === base; card.classList.toggle('selected', on); card.setAttribute('aria-checked', on ? 'true' : 'false'); if (on) capable = card.dataset.ctx === '1'; }); const ctxOn = !!document.getElementById('appSettingsOpusContext1m')?.checked; select.value = base && capable && ctxOn ? `${base}[1m]` : base; // A model with no 1M variant makes the switch inert; say so instead of // leaving a toggle that looks like it does something. const row = document.getElementById('appSettingsContextRow'); const desc = document.getElementById('appSettingsContextDesc'); const inert = !!base && !capable; row?.classList.toggle('set-row-disabled', inert); if (desc) { desc.textContent = inert ? 'The selected model has no 1M variant.' : base ? 'Available for Fable 5.1, Fable 5, Opus 5.5, Opus and Opus 4.6.' : 'With no model pinned, this starts new sessions on Opus with a 1M window.'; } }, _buildEffortSegment() { const select = document.getElementById('appSettingsThinkingEffort'); const seg = document.getElementById('appSettingsEffortSegment'); if (!select || !seg || seg.dataset.built === '1' || !select.options) return; seg.innerHTML = ''; [...select.options].forEach(opt => { const btn = document.createElement('button'); btn.type = 'button'; btn.setAttribute('role', 'radio'); btn.dataset.value = opt.value; btn.textContent = opt.textContent; btn.addEventListener('click', () => { select.value = opt.value; this._syncEffortSegment(); }); seg.appendChild(btn); }); seg.dataset.built = '1'; }, _syncEffortSegment() { const select = document.getElementById('appSettingsThinkingEffort'); const seg = document.getElementById('appSettingsEffortSegment'); if (!select || !seg) return; seg.querySelectorAll('button').forEach(btn => { const on = btn.dataset.value === (select.value || ''); btn.classList.toggle('selected', on); btn.setAttribute('aria-checked', on ? 'true' : 'false'); }); }, /** * Live filter across every section. Everything stays mounted (that is the * point of the single-document layout), so a search only hides units that do * not match, then collapses groups and sections left with nothing visible. */ _filterSettings(query) { const doc = document.getElementById('appSettingsDoc'); if (!doc) return; const q = (query || '').trim().toLowerCase(); const UNIT = '.set-row, .set-chip, .set-modelgrid, .set-minigrid, .event-type-grid, #appSettingsShortcutsList'; const units = [...doc.querySelectorAll(UNIT)]; let anyVisible = false; units.forEach(unit => { if (!q) { unit.classList.remove('set-hit-hidden'); return; } const hay = `${unit.dataset?.search || ''} ${unit.textContent || ''}`.toLowerCase(); const hit = hay.includes(q); unit.classList.toggle('set-hit-hidden', !hit); if (hit) anyVisible = true; }); // A chip wrapper is only empty when every chip inside it is hidden. doc.querySelectorAll('.set-chips').forEach(wrap => { const hasVisible = [...wrap.querySelectorAll('.set-chip')].some(c => !c.classList.contains('set-hit-hidden')); wrap.classList.toggle('set-hit-hidden', !!q && !hasVisible); }); doc.querySelectorAll('.set-group').forEach(group => { const hasVisible = [...group.querySelectorAll(UNIT)].some(u => !u.classList.contains('set-hit-hidden')); group.classList.toggle('set-hit-hidden', !!q && !hasVisible); // An Advanced group that matches must open, or the hit stays invisible. if (q && hasVisible) group.classList.add('open'); }); doc.querySelectorAll('.set-section').forEach(section => { const hasVisible = [...section.querySelectorAll('.set-group')].some(g => !g.classList.contains('set-hit-hidden')); section.classList.toggle('set-hit-hidden', !!q && !hasVisible); }); // The live preview sits outside any group, so it survives the sweep above; // a search is asking for one row, not for the scale model around it. doc.querySelectorAll('.set-preview').forEach(pv => pv.classList.toggle('set-hit-hidden', !!q)); const empty = document.getElementById('appSettingsSearchEmpty'); if (empty) empty.hidden = !q || anyVisible; if (!q) doc.querySelectorAll('.set-group-advanced').forEach(g => g.classList.remove('open')); }, closeAppSettings() { this._toggleSettingsJump(false); document.getElementById('appSettingsModal').classList.remove('active'); // Deactivate focus trap and restore focus if (this.activeFocusTrap) { this.activeFocusTrap.deactivate(); this.activeFocusTrap = null; } }, // ─────────────────────────────────────────────────────────────── // Self-Update (App Settings → Updates). Backend: src/web/self-update.ts. // ─────────────────────────────────────────────────────────────── /** Friendly label for an in-flight update phase. */ _updatePhaseText(phase) { return { queued: 'Queued…', preparing: 'Preparing…', stashing: 'Stashing local changes…', fetching: 'Fetching release…', checkout: 'Checking out release…', installing: 'Installing dependencies…', building: 'Building…', restarting: 'Restarting Codeman…', }[phase] || phase; }, /** Populate the version row and clear transient UI when the modal opens. */ _initUpdatesSection() { const verEl = this.$('updateCurrentVersion'); if (verEl) verEl.textContent = (this.$('versionDisplay')?.textContent || '').trim() || '—'; for (const id of ['updateResult', 'updateActionRow', 'updateNotes', 'updateProgress']) { const el = this.$(id); if (el) el.style.display = 'none'; } this._updateCheck = null; }, /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); const show = (html) => { if (out) { out.style.display = 'block'; out.innerHTML = html; } }; if (apply && !confirm('Add missing MCP servers to every enabled CLI\'s config file?')) return; show('Working…'); const res = apply ? await this._apiPost('/api/mcp-sync', {}) : await this._api('/api/mcp-sync'); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } if (!res || !res.ok || !body || body.success === false) { show(escapeHtml(body?.error || 'MCP sync failed.')); return; } const data = body.data; const rows = data.targets.map((t) => { if (t.status !== 'ok') return `
  • ${escapeHtml(t.label)}: not touched (${escapeHtml(t.error || 'unreadable')})
  • `; const verb = data.applied ? 'added' : 'would add'; const parts = [t.added.length ? `${verb} ${t.added.map(escapeHtml).join(', ')}` : 'up to date']; if (t.skipped.length) parts.push(`can't express ${t.skipped.map(escapeHtml).join(', ')}`); return `
  • ${escapeHtml(t.label)} (${t.servers.length} servers): ${parts.join('; ')}
  • `; }); const conflicts = data.conflicts.length ? `

    Defined differently across CLIs, left unchanged: ${data.conflicts.map(escapeHtml).join(', ')}

    ` : ''; const unsupported = data.unsupported?.length ? `

    No MCP config support for: ${data.unsupported.map(escapeHtml).join(', ')}

    ` : ''; show(`${conflicts}${unsupported}`); }, _setUpdateResult(html) { const el = this.$('updateResult'); if (el) { el.style.display = 'block'; el.innerHTML = html; } }, _setUpdateProgress(html) { const el = this.$('updateProgress'); if (el) { el.style.display = 'block'; el.innerHTML = html; } }, /** Manual "Check for updates" — asks the server to query GitHub. */ async checkForUpdate() { const btn = this.$('updateCheckBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; } const data = await this._apiJson('/api/system/update/check'); if (btn) { btn.disabled = false; btn.textContent = 'Check now'; } const actionRow = this.$('updateActionRow'); const notes = this.$('updateNotes'); if (actionRow) actionRow.style.display = 'none'; if (notes) notes.style.display = 'none'; if (!data) { this._setUpdateResult('Could not check for updates. Try again later.'); return; } this._updateCheck = data; const verEl = this.$('updateCurrentVersion'); if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`; // `docker-compose` self-updates in place like `git` does — the container // restarts itself. Anything else cannot. if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') { const hint = data.supervisor === 'docker-compose' ? 'Update from the Docker host with docker/Start-Codeman.sh.' : 'Update with npm i -g aicodeman@latest.'; this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`); return; } if (data.selfUpdateEnabled === false) { this._setUpdateResult('In-app updates are disabled on this server (CODEMAN_DISABLE_SELF_UPDATE=1).'); return; } if (data.error && !data.updateAvailable) { this._setUpdateResult(escapeHtml(data.error)); return; } // A container release that changes the ENVIRONMENT (Dockerfile, compose file // or new .env keys) cannot be applied by the container restarting itself, so // the update button is never offered — the host command is, instead. The // server re-checks this on POST, so hiding the button is UX, not the gate. const blockers = data.environment?.blockers || []; if (data.updateAvailable && blockers.length > 0) { const reasons = blockers .map((b) => { const details = b.details?.length ? `
    ${escapeHtml(b.details.join(' '))}` : ''; return `
  • ${escapeHtml(b.message)}${details}
  • `; }) .join(''); this._setUpdateResult( `v${escapeHtml(data.latestVersion || '')} needs a rebuild on the Docker host` + ` (current v${escapeHtml(data.currentVersion || '')}):` + `Run ${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')} there to apply it.` ); if (notes && data.notes) { notes.style.display = 'block'; notes.textContent = data.notes; } return; } if (data.updateAvailable && data.latestVersion) { this._setUpdateResult( `Update available: v${escapeHtml(data.latestVersion)}  (current v${escapeHtml(data.currentVersion || '')})` ); const label = this.$('updateActionLabel'); if (label) label.textContent = `Update to v${data.latestVersion}`; if (actionRow) actionRow.style.display = 'flex'; const nowBtn = this.$('updateNowBtn'); if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Update now'; } if (notes && data.notes) { notes.style.display = 'block'; notes.textContent = data.notes; } } else { this._setUpdateResult(`You're up to date (v${escapeHtml(data.currentVersion || '')}).`); } }, /** Start the update, then poll status across the service restart. */ async startSelfUpdate() { const target = this._updateCheck?.latestVersion ? `v${this._updateCheck.latestVersion}` : 'the latest release'; if (!confirm(`Update Codeman to ${target}? The server will restart and this page will reload.`)) return; const btn = this.$('updateNowBtn'); if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; } const res = await this._apiPost('/api/system/update', {}); if (!res || !res.ok) { let msg = 'Failed to start the update.'; try { const j = await res.json(); if (typeof j?.error === 'string' && j.error) msg = j.error; } catch {} this._setUpdateProgress(`${escapeHtml(msg)}`); if (btn) { btn.disabled = false; btn.textContent = 'Update now'; } return; } const actionRow = this.$('updateActionRow'); if (actionRow) actionRow.style.display = 'none'; const notes = this.$('updateNotes'); if (notes) notes.style.display = 'none'; this._setUpdateProgress('Starting update…'); this._pollUpdateStatus(); }, _stopUpdatePolling() { if (this._updatePollTimer) { clearInterval(this._updatePollTimer); this._updatePollTimer = null; } }, /** * Poll the status file every 1.5s. Survives the connection drop while the * server restarts (fetch throws → "restarting"), then reads the reconciled * terminal state from the freshly-booted server. */ _pollUpdateStatus() { this._stopUpdatePolling(); const terminal = new Set(['completed', 'completed-needs-manual-restart', 'failed', 'idle']); const poll = async () => { let data = null; try { const res = await fetch('/api/system/update/status'); if (res.ok) { const env = await res.json(); data = env && env.success === true ? env.data : env; } } catch { /* server restarting — keep polling */ } if (!data) { this._setUpdateProgress('↻ Restarting Codeman…'); return; } if (!terminal.has(data.phase)) { // Prefer the live status message — the updater's heartbeat enriches it with // the latest npm/build output line so a slow step doesn't look frozen — and // fall back to the static phase label. Append total elapsed so the counter // keeps ticking between heartbeats: a clear "still working" signal. const label = (data.message && data.message.trim()) ? data.message.trim() : this._updatePhaseText(data.phase); let elapsed = ''; if (data.startedAt) { const secs = Math.max(0, Math.round((Date.now() - data.startedAt) / 1000)); elapsed = ` · ${secs}s`; } this._setUpdateProgress(` ${escapeHtml(label)}${elapsed}`); return; } this._stopUpdatePolling(); if (data.phase === 'completed') { let html = `✓ Updated to v${escapeHtml(data.toVersion || '')}. Reloading…`; if (data.stashRef) { html += `
    Local changes stashed as ${escapeHtml(data.stashRef)} — run git stash pop to restore.`; } this._setUpdateProgress(html); setTimeout(() => location.reload(), 2500); } else if (data.phase === 'completed-needs-manual-restart') { this._setUpdateProgress( `Update staged. Restart Codeman to apply:
    ${escapeHtml(data.manualRestartCommand || 'restart codeman web')}` ); } else if (data.phase === 'failed') { let html = `✗ ${escapeHtml(data.message || 'Update failed')}.`; if (data.error) html += `
    ${escapeHtml(data.error)}`; html += `
    The previous version is still running.`; if (data.stashRef) { html += `
    Local changes stashed as ${escapeHtml(data.stashRef)}.`; } this._setUpdateProgress(html); const nowBtn = this.$('updateNowBtn'); const actionRow = this.$('updateActionRow'); if (nowBtn) { nowBtn.disabled = false; nowBtn.textContent = 'Try again'; } if (actionRow) actionRow.style.display = 'flex'; } }; poll(); this._updatePollTimer = setInterval(poll, 1500); }, /** * Is `tool` installed on the server? Reads `window.__codemanCliAvailable`, * injected by renderIndexHtml (see the comment there for why this is injected * rather than fetched per surface). * * Unknown reads as AVAILABLE. A missing flag means the page was rendered by a * build that predates the injection, or by a solo popup: hiding every run * button on a doubt would leave nothing to click, and the pre-existing failure * mode for a genuinely missing CLI is just an error toast. */ isCliAvailable(tool) { const flags = window.__codemanCliAvailable; if (!flags || typeof flags !== 'object') return true; return flags[tool] !== false; }, /** Render the registry's enabled, available CLIs as welcome-screen actions. */ renderWelcomeCliActions() { const container = document.getElementById('welcomeCliActions'); if (!container) return; const catalog = Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []; container.replaceChildren(); for (const cli of catalog) { if (!cli.enabled || !this.isCliAvailable(cli.id)) continue; const btn = document.createElement('button'); btn.type = 'button'; btn.className = `welcome-btn welcome-btn-cli welcome-btn-${cli.id}`; btn.dataset.mode = cli.id; const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); icon.setAttribute('width', '20'); icon.setAttribute('height', '20'); icon.setAttribute('viewBox', '0 0 24 24'); icon.setAttribute('fill', 'none'); icon.setAttribute('stroke', 'currentColor'); icon.setAttribute('stroke-width', '2'); icon.setAttribute('aria-hidden', 'true'); const play = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); play.setAttribute('points', '5 3 19 12 5 21 5 3'); icon.appendChild(play); btn.appendChild(icon); // Same "Run