/** * Settings Module - MAMA OS Settings Management * @module modules/settings * @version 1.0.0 * * Handles Settings tab functionality including: * - Load current configuration * - Save configuration changes * - Form validation * - Gateway enable/disable toggles */ /* eslint-env browser */ import { showToast, escapeHtml, escapeAttr, getElementByIdOrNull } from '../utils/dom.js'; import { formatModelName } from '../utils/format.js'; import { DebugLogger } from '../utils/debug-logger.js'; import { reportPageContext } from '../utils/ui-commands.js'; import { API, type ApiConfigResponse, type ApiRoleDefinition, type CronJob, type CronJobsResponse, type EffortLevel, type McpServer, type McpServersResponse, type MultiAgentAgentsResponse, type SkillsResponse, } from '../utils/api.js'; const logger = new DebugLogger('Settings'); type AgentBackend = 'claude' | 'codex-mcp'; type SettingsFilterValue = 'loading' | 'error' | 'success' | ''; type SettingsPayloadToolConfig = { gateway: string[]; mcp: string[]; mcp_config: string; }; type SettingsPayload = { discord: { enabled: boolean; token: string; default_channel_id: string; }; slack: { enabled: boolean; bot_token: string; app_token: string; }; telegram: { enabled: boolean; token: string; }; chatwork: { enabled: boolean; api_token: string; }; heartbeat: { enabled: boolean; interval: number; quiet_start: number; quiet_end: number; }; use_claude_cli: boolean; agent: { backend: AgentBackend; model: string; effort?: EffortLevel; max_turns: number; timeout: number; tools: SettingsPayloadToolConfig; }; token_budget: { daily_limit?: number; alert_threshold?: number; }; metrics: { enabled: boolean; retention_days: number; }; timeouts: { request_ms: number; agent_ms: number; session_ms: number; workflow_step_ms: number; workflow_max_ms: number; ultrawork_ms: number; }; }; // Model options by backend (single source of truth) // Claude models: https://platform.claude.com/docs/en/about-claude/models/overview const MODEL_OPTIONS: Record = { 'codex-mcp': [ 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-4.1', 'gpt-4o', 'gpt-4o-mini', 'o1', 'o1-mini', 'o3-mini', ], claude: [ // Latest models (4.6) 'claude-opus-4-6', 'claude-sonnet-4-6', // Previous gen (4.5) 'claude-opus-4-5-20251101', 'claude-sonnet-4-5-20250929', 'claude-haiku-4-5-20251001', // Legacy models 'claude-sonnet-4-20250514', 'claude-opus-4-20250514', 'claude-3-7-sonnet-20250219', 'claude-3-haiku-20240307', ], }; const EFFORT_SUPPORTED_MODELS = new Set(['claude-opus-4-6', 'claude-sonnet-4-6']); const MAX_EFFORT_MODELS = new Set(['claude-opus-4-6']); /** * Settings Module Class */ export class SettingsModule { config: ApiConfigResponse | null = null; mcpServersData: McpServersResponse = { servers: [] }; multiAgentData: MultiAgentAgentsResponse = { agents: [] }; initialized = false; backendListenersInitialized = false; delegatedListenersInitialized = false; constructor() {} private supportsEffortModel(model: string): boolean { return EFFORT_SUPPORTED_MODELS.has(model); } private supportsMaxEffortModel(model: string): boolean { return MAX_EFFORT_MODELS.has(model); } private normalizeEffortForModel(model: string, effort: EffortLevel): EffortLevel { if (effort === 'max' && !this.supportsMaxEffortModel(model)) { return 'high'; } return effort; } private getEffortLevelsForModel(model: string): EffortLevel[] { if (this.supportsMaxEffortModel(model)) { return ['low', 'medium', 'high', 'max']; } return ['low', 'medium', 'high']; } private buildEffortOptions(model: string, selectedEffort: EffortLevel): string { return this.getEffortLevelsForModel(model) .map((effort) => { const selected = selectedEffort === effort ? ' selected' : ''; const label = effort === 'max' ? `${effort} (Opus)` : effort; return ``; }) .join(''); } private refreshAgentEffortControls(agentId: string, model: string): void { const effortContainer = getElementByIdOrNull(`agent-effort-container-${agentId}`); if (!effortContainer) { return; } const supportsEffort = this.supportsEffortModel(model); effortContainer.style.display = supportsEffort ? 'block' : 'none'; const effortSelect = getElementByIdOrNull(`agent-effort-${agentId}`); if (!effortSelect) { return; } const currentEffort = (effortSelect.value || 'medium') as EffortLevel; const normalizedEffort = this.normalizeEffortForModel(model, currentEffort); effortSelect.innerHTML = this.buildEffortOptions(model, normalizedEffort); effortSelect.value = normalizedEffort; } /** * Parse and validate required integer input. */ private parseIntegerInput(id: string, min: number, max: number, fallback: number | null): number { const raw = this.getValue(id); if (raw === null) { if (fallback === null) { throw new Error(`필수 값이 비어 있습니다: ${id}`); } return fallback; } const trimmed = raw.trim(); if (!trimmed) { if (fallback === null) { throw new Error(`필수 값이 비어 있습니다: ${id}`); } return fallback; } const parsed = Number(trimmed); if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { throw new Error(`숫자 형식이 유효하지 않습니다: ${id}`); } if (parsed < min || parsed > max) { throw new Error(`${id}는 ${min}~${max} 사이여야 합니다.`); } return parsed; } private parseOptionalNumber( id: string, fieldName: string, options: { min: number; max?: number; integerOnly?: boolean; } ): number | undefined { const raw = this.getValue(id); const trimmed = raw.trim(); if (!trimmed) { return undefined; } const parsed = Number(trimmed); if (!Number.isFinite(parsed)) { throw new Error(`${fieldName}은(는) 유효한 숫자여야 합니다.`); } if (parsed < options.min) { throw new Error(`${fieldName}은(는) ${options.min} 이상이어야 합니다.`); } if (options.max !== undefined && parsed > options.max) { throw new Error(`${fieldName}은(는) ${options.max} 이하여야 합니다.`); } if (options.integerOnly !== false && !Number.isInteger(parsed)) { throw new Error(`${fieldName}은(는) 정수여야 합니다.`); } return parsed; } /** * Initialize settings module */ async init(): Promise { if (this.initialized) { return; } this.initialized = true; await this.loadSettings(); this.initBackendModelBinding(); this.initDelegatedEventHandlers(); } initDelegatedEventHandlers(): void { if (this.delegatedListenersInitialized) { return; } this.delegatedListenersInitialized = true; document.addEventListener('change', (e: Event) => { const target = e.target; if (!(target instanceof HTMLElement)) { return; } const actionElement = target.closest('[data-action]'); const action = actionElement?.dataset.action; if (!action || !actionElement) { return; } if (action === 'agent-toggle') { const checkbox = actionElement as HTMLInputElement; const agentId = checkbox.dataset.agentId || ''; if (agentId) { void this.toggleAgent(agentId, checkbox.checked); } return; } if (action === 'agent-backend') { const select = actionElement as HTMLSelectElement; const agentId = select.dataset.agentId || ''; if (agentId) { this.onAgentBackendChange(agentId); } return; } if (action === 'agent-model') { const select = actionElement as HTMLSelectElement; const agentId = select.dataset.agentId || ''; if (agentId) { this.onAgentModelChange(agentId); } return; } if (action === 'cron-toggle') { const checkbox = actionElement as HTMLInputElement; const cronId = checkbox.dataset.cronId || ''; if (cronId) { void this.toggleCronJob(cronId, checkbox.checked); } return; } }); document.addEventListener('click', (e: MouseEvent) => { const target = e.target; if (!(target instanceof HTMLElement)) { return; } // Handle agent-save button click const saveButton = target.closest('[data-action="agent-save"]'); if (saveButton) { e.preventDefault(); const agentId = saveButton.dataset.agentId || ''; if (agentId) { void this.saveAgentConfig(agentId); } return; } // Handle cron-delete button click const deleteButton = target.closest('[data-action="cron-delete"]'); if (deleteButton) { e.preventDefault(); const cronId = deleteButton.dataset.cronId || ''; if (cronId) { void this.deleteCronJob(cronId); } return; } const goAgentsButton = target.closest('[data-action="go-agents-tab"]'); if (goAgentsButton) { e.preventDefault(); if (typeof window.switchTab === 'function') { window.switchTab('agents'); } } }); } /** * Load current settings from API */ async loadSettings(): Promise { this.setStatus('Loading...'); try { this.config = await API.get('/api/config'); // Load MCP servers data try { this.mcpServersData = await API.get('/api/mcp-servers'); } catch (e) { logger.warn('MCP servers data unavailable:', e); this.mcpServersData = { servers: [] }; } // Load multi-agent data (F3) try { this.multiAgentData = await API.get('/api/multi-agent/agents'); } catch (e) { logger.warn('Multi-agent data unavailable:', e); this.multiAgentData = { agents: [] }; } this.populateForm(); this.setStatus(''); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Load error:', message); this.setStatus(`Error: ${message}`, 'error'); } } /** * Populate form with current config values */ populateForm(): void { if (!this.config) { return; } // Discord this.setCheckbox('settings-discord-enabled', this.config.discord?.enabled); this.setValue('settings-discord-token', this.config.discord?.token || '', true); this.setValue('settings-discord-channel', this.config.discord?.default_channel_id || ''); // Slack this.setCheckbox('settings-slack-enabled', this.config.slack?.enabled); this.setValue('settings-slack-bot-token', this.config.slack?.bot_token || '', true); this.setValue('settings-slack-app-token', this.config.slack?.app_token || '', true); // Telegram this.setCheckbox('settings-telegram-enabled', this.config.telegram?.enabled); this.setValue('settings-telegram-token', this.config.telegram?.token || '', true); // Chatwork this.setCheckbox('settings-chatwork-enabled', this.config.chatwork?.enabled); this.setValue('settings-chatwork-token', this.config.chatwork?.api_token || '', true); // Heartbeat this.setCheckbox('settings-heartbeat-enabled', this.config.heartbeat?.enabled); this.setValue( 'settings-heartbeat-interval', Math.round((this.config.heartbeat?.interval || 1800000) / 60000) ); this.setValue('settings-heartbeat-quiet-start', this.config.heartbeat?.quiet_start ?? 23); this.setValue('settings-heartbeat-quiet-end', this.config.heartbeat?.quiet_end ?? 8); // Agent const backend = (this.config.agent?.backend || 'claude') as AgentBackend; const model = this.config.agent?.model || 'claude-sonnet-4-6'; const effort = (this.config.agent?.effort || 'medium') as EffortLevel; this.setSelectValue('settings-agent-backend', backend); this.updateModelOptions(backend, model); const normalizedModel = this.getNormalizedModelForBackend(backend, model); this.setSelectValue('settings-agent-model', normalizedModel); this.setSelectValue('settings-agent-effort', effort); this.updateEffortVisibility(normalizedModel); this.setValue('settings-agent-max-turns', this.config.agent?.max_turns || 10); this.setValue( 'settings-agent-timeout', Math.round((this.config.agent?.timeout || 300000) / 1000) ); // Timeouts this.populateTimeouts(); // Tool Mode this.populateToolMode(); // Role Permissions this.populateRoles(); // Multi-Agent Team (F3) this.populateMultiAgentSection(); // Metrics this.populateMetricsSection(); // Skills + Token Budget + Cron this.populateSkillsSection(); this.populateTokenSection(); this.populateCronSection(); reportPageContext('settings', { pageType: 'settings-overview', gateways: { discord: this.config.discord?.enabled ?? false, slack: this.config.slack?.enabled ?? false, telegram: this.config.telegram?.enabled ?? false, chatwork: this.config.chatwork?.enabled ?? false, }, agent: { backend: this.config.agent?.backend ?? 'claude', model: this.config.agent?.model ?? null, max_turns: this.config.agent?.max_turns ?? null, timeout: this.config.agent?.timeout ?? null, }, multiAgentCount: this.multiAgentData.agents.length, mcpServerCount: this.mcpServersData.servers.length, }); } /** * Populate timeouts section from config */ populateTimeouts(): void { if (!this.config) { return; } const t = this.config.timeouts; if (!t) { return; } this.setValue('settings-timeout-request', Math.round((t.request_ms ?? 120000) / 1000)); this.setValue('settings-timeout-agent', Math.round((t.agent_ms ?? 300000) / 1000)); this.setValue('settings-timeout-session', Math.round((t.session_ms ?? 1800000) / 60000)); this.setValue('settings-timeout-wf-step', Math.round((t.workflow_step_ms ?? 300000) / 1000)); this.setValue('settings-timeout-wf-max', Math.round((t.workflow_max_ms ?? 1800000) / 60000)); this.setValue('settings-timeout-ultrawork', Math.round((t.ultrawork_ms ?? 300000) / 1000)); } /** * Populate role permissions from config */ populateRoles(): void { const container = getElementByIdOrNull('settings-roles-container'); if (!container || !this.config.roles) { return; } const { definitions, sourceMapping } = this.config.roles; if (!definitions || !sourceMapping) { return; } // Build reverse mapping: role -> sources const roleSources: Record = {}; for (const [source, role] of Object.entries(sourceMapping)) { if (!roleSources[role]) { roleSources[role] = []; } roleSources[role].push(source); } // Render each role const roleColors: Record = { os_agent: { badge: 'green', label: 'Full Access' }, chat_bot: { badge: 'yellow', label: 'Limited' }, }; const roleIcons: Record = { os_agent: '🖥️', chat_bot: '🤖', }; const roleDefs = definitions as Record; const html = Object.entries(roleDefs) .map(([roleName, roleConfig]) => { const sources = roleSources[roleName] || []; const color = roleColors[roleName] || { badge: 'gray', label: 'Custom' }; const icon = roleIcons[roleName] || '⚙️'; const displayName = escapeHtml( roleName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()) ); const allowedTools = roleConfig.allowedTools || []; const blockedTools = roleConfig.blockedTools || []; const hasSystemControl = roleConfig.systemControl; const hasSensitiveAccess = roleConfig.sensitiveAccess; const model = roleConfig.model || 'default'; const maxTurns = roleConfig.maxTurns; // Format model name for display (and escape) const displayModel = escapeHtml(formatModelName(model)); return `
${icon}

${displayName}

${color.label}
Model: ${displayModel} ${ maxTurns ? `| ${escapeHtml(String(maxTurns))} turns` : '' }
Source: ${sources.map((s) => `${escapeHtml(s)}`).join(' ')}
Allowed: ${escapeHtml(allowedTools.join(', '))}
${blockedTools.length > 0 ? `
Blocked: ${escapeHtml(blockedTools.join(', '))}
` : ''} ${ hasSystemControl || hasSensitiveAccess ? `
Permissions: ${hasSystemControl ? 'systemControl' : ''} ${hasSensitiveAccess ? 'sensitiveAccess' : ''}
` : '' }
`; }) .join(''); container.innerHTML = html; } /** * Populate tool selection checkboxes */ populateToolMode(): void { const tools = this.config.agent?.tools || { gateway: ['*'], mcp: [] }; const gatewayTools = tools.gateway || ['*']; const mcpTools = tools.mcp || []; // Set Gateway tool checkboxes const gatewayCheckboxes = document.querySelectorAll('.gateway-tool'); const isGatewayAll = gatewayTools.includes('*'); gatewayCheckboxes.forEach((cb) => { if (isGatewayAll) { cb.checked = true; } else { cb.checked = gatewayTools.includes(cb.value); } }); // Set Select All checkbox const gatewaySelectAll = getElementByIdOrNull('gateway-select-all'); if (gatewaySelectAll) { gatewaySelectAll.checked = isGatewayAll || this.allChecked('.gateway-tool'); } // Dynamically render MCP servers from API this.renderMCPServers(mcpTools); // Update summary this.updateToolSummary(); } /** * Render MCP servers dynamically from loaded data */ renderMCPServers(selectedTools: string[] = []): void { const container = getElementByIdOrNull('mcp-tools-list'); if (!container) { return; } const servers = (this.mcpServersData?.servers || []) as McpServer[]; const isMCPAll = selectedTools.includes('*'); if (servers.length === 0) { container.innerHTML = `

No MCP servers configured. Add servers to ~/.mama/mama-mcp-config.json

`; return; } const serverColors: Record = { 'brave-devtools': { border: 'border-blue-200', bg: 'bg-blue-50', icon: '🌐' }, 'brave-search': { border: 'border-orange-200', bg: 'bg-orange-50', icon: '🔍' }, mama: { border: 'border-purple-200', bg: 'bg-purple-50', icon: '🧠' }, default: { border: 'border-gray-200', bg: 'bg-gray-50', icon: '🔌' }, }; const html = servers .map((server) => { const serverName = server.name || ''; const colors = serverColors[serverName] || serverColors['default']; const toolValue = `mcp__${serverName}__*`; const isChecked = isMCPAll || selectedTools.includes(toolValue); // Escape server.name for safe HTML rendering (XSS prevention) const safeName = escapeHtml(serverName); const safeToolValue = escapeAttr(toolValue); return ` `; }) .join(''); container.innerHTML = html; // Update Select All checkbox const mcpSelectAll = getElementByIdOrNull('mcp-select-all'); if (mcpSelectAll) { mcpSelectAll.checked = isMCPAll || this.allChecked('.mcp-tool'); } } /** * Check if all checkboxes of a class are checked */ allChecked(selector: string): boolean { const checkboxes = document.querySelectorAll(selector); return Array.from(checkboxes).every((cb) => cb.checked); } /** * Toggle all Gateway tools */ toggleAllGateway(checked: boolean): void { document.querySelectorAll('.gateway-tool').forEach((cb) => { cb.checked = checked; }); this.updateToolSummary(); } /** * Toggle all MCP tools */ toggleAllMCP(checked: boolean): void { document.querySelectorAll('.mcp-tool').forEach((cb) => { cb.checked = checked; }); this.updateToolSummary(); } /** * Update tool summary display */ updateToolSummary(): void { const gatewayCount = document.querySelectorAll('.gateway-tool:checked').length; const mcpCount = document.querySelectorAll('.mcp-tool:checked').length; const summaryEl = getElementByIdOrNull('tool-summary'); if (summaryEl) { summaryEl.textContent = `Gateway: ${gatewayCount} tools | MCP: ${mcpCount} tools`; } } /** * Save settings and restart daemon to apply changes */ async saveAndRestart(): Promise { this.setStatus('Saving...'); try { const updates = this.collectFormData(); await API.put('/api/config', updates); this.setStatus('Saved! Restarting...', 'success'); showToast('Settings saved. Restarting daemon...'); // Trigger restart after save try { await API.post('/api/restart', {}); } catch { // Expected: connection drops when server exits } const isServiceReady = await this.waitForServiceAfterRestart(); if (!isServiceReady) { this.setStatus('Restarted, but reconnect timed out. Please refresh manually.', 'error'); showToast('Restart request sent. Auto reconnect timed out - please refresh page.'); return; } this.setStatus('Reconnected. Reloading page...', 'success'); setTimeout(() => { window.location.reload(); }, 400); } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error('Save error:', message); this.setStatus(`Error: ${message}`, 'error'); showToast(`Failed to save: ${message}`); } } /** * Wait for service recovery after restart by polling dashboard status endpoint. */ private async waitForServiceAfterRestart(): Promise { const maxAttempts = 40; const intervalMs = 1000; const readinessChecks = ['/api/health', '/api/dashboard/status']; // Server waits 500ms + shell sleeps 1s before stopping, so wait at least 2.5s // before first poll to ensure old server is actually down const initialDelayMs = 2500; this.setStatus('Restarting... waiting for shutdown', ''); await new Promise((resolve) => setTimeout(resolve, initialDelayMs)); for (let attempt = 1; attempt <= maxAttempts; attempt++) { this.setStatus(`Restarting... reconnecting (${attempt}/${maxAttempts})`, ''); let isReady = false; for (const endpoint of readinessChecks) { try { // Use shared API client for strict JSON response parsing and consistent errors. await API.get(endpoint); logger.debug('[Settings] Service ready check passed:', endpoint); isReady = true; break; } catch { logger.debug('[Settings] Service not ready yet:', endpoint); } } if (isReady) { return true; } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } return false; } /** * Collect form data into config update object */ collectFormData(): SettingsPayload { const backend = (this.getSelectValue('settings-agent-backend') || 'claude') as AgentBackend; const model = this.getSelectValue('settings-agent-model'); const effort = (this.getSelectValue('settings-agent-effort') || 'medium') as EffortLevel; const useClaudeCli = backend === 'claude'; const resolvedModel = model || (backend === 'codex-mcp' ? 'gpt-5.2-codex' : 'claude-sonnet-4-6'); const normalizedEffort = this.supportsEffortModel(resolvedModel) ? this.normalizeEffortForModel(resolvedModel, effort) : undefined; // Get token values - if empty and original was masked, keep original const discordToken = this.getTokenValue('settings-discord-token', this.config?.discord?.token); const slackBotToken = this.getTokenValue( 'settings-slack-bot-token', this.config?.slack?.bot_token ); const slackAppToken = this.getTokenValue( 'settings-slack-app-token', this.config?.slack?.app_token ); const telegramToken = this.getTokenValue( 'settings-telegram-token', this.config?.telegram?.token ); const chatworkToken = this.getTokenValue( 'settings-chatwork-token', this.config?.chatwork?.api_token ); return { discord: { enabled: this.getCheckbox('settings-discord-enabled'), token: discordToken, default_channel_id: this.getValue('settings-discord-channel'), }, slack: { enabled: this.getCheckbox('settings-slack-enabled'), bot_token: slackBotToken, app_token: slackAppToken, }, telegram: { enabled: this.getCheckbox('settings-telegram-enabled'), token: telegramToken, }, chatwork: { enabled: this.getCheckbox('settings-chatwork-enabled'), api_token: chatworkToken, }, heartbeat: { enabled: this.getCheckbox('settings-heartbeat-enabled'), interval: this.parseIntegerInput('settings-heartbeat-interval', 1, 1440, 30) * 60000, quiet_start: this.parseIntegerInput('settings-heartbeat-quiet-start', 0, 23, 23), quiet_end: this.parseIntegerInput('settings-heartbeat-quiet-end', 0, 23, 8), }, use_claude_cli: useClaudeCli, agent: { backend, model: resolvedModel, // Effort for Claude 4.6 models (adaptive thinking). 'max' is Opus-only. effort: normalizedEffort, max_turns: this.parseIntegerInput('settings-agent-max-turns', 1, 100, 10), timeout: this.parseIntegerInput('settings-agent-timeout', 1, 600, 300) * 1000, tools: this.collectToolModeData(), }, token_budget: { // Keep existing integer constraint for daily limit to avoid partial values. daily_limit: this.parseOptionalNumber('settings-token-daily-limit', 'daily_limit', { min: 0, integerOnly: true, }), alert_threshold: this.parseOptionalNumber( 'settings-token-alert-threshold', 'alert_threshold', { min: 0, max: 100, integerOnly: false, } ), }, metrics: { enabled: this.getCheckbox('settings-metrics-enabled'), retention_days: this.parseIntegerInput('settings-metrics-retention', 1, 90, 7), }, timeouts: { request_ms: this.parseIntegerInput('settings-timeout-request', 0, 600, 120) * 1000, agent_ms: this.parseIntegerInput('settings-timeout-agent', 0, 3600, 300) * 1000, session_ms: this.parseIntegerInput('settings-timeout-session', 0, 1440, 30) * 60000, workflow_step_ms: this.parseIntegerInput('settings-timeout-wf-step', 0, 7200, 300) * 1000, workflow_max_ms: this.parseIntegerInput('settings-timeout-wf-max', 0, 1440, 30) * 60000, ultrawork_ms: this.parseIntegerInput('settings-timeout-ultrawork', 0, 7200, 300) * 1000, }, }; } initBackendModelBinding(): void { if (this.backendListenersInitialized) { return; } this.backendListenersInitialized = true; const backendSelect = getElementByIdOrNull('settings-agent-backend'); if (!backendSelect) { return; } backendSelect.addEventListener('change', () => { const backend = (this.getSelectValue('settings-agent-backend') || 'claude') as AgentBackend; const currentModel = this.getSelectValue('settings-agent-model'); this.updateModelOptions(backend, currentModel); const normalizedModel = this.getNormalizedModelForBackend(backend, currentModel); this.setSelectValue('settings-agent-model', normalizedModel); this.updateEffortVisibility(normalizedModel); }); // Also listen for model changes to update effort visibility const modelSelect = getElementByIdOrNull('settings-agent-model'); if (modelSelect) { modelSelect.addEventListener('change', () => { const model = this.getSelectValue('settings-agent-model'); this.updateEffortVisibility(model); }); } } updateModelOptions(backend: AgentBackend, currentModel: string): void { const select = getElementByIdOrNull('settings-agent-model'); if (!select) { return; } const modelList = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude; const normalized = this.getNormalizedModelForBackend(backend, currentModel); select.innerHTML = modelList .map( (m) => `` ) .join(''); // Update effort visibility when model options change this.updateEffortVisibility(normalized); } /** * Show/hide effort level dropdown based on model selection * Effort applies to Claude 4.6 models, with 'max' reserved for Opus. */ updateEffortVisibility(model: string): void { const effortContainer = getElementByIdOrNull('settings-effort-container'); if (!effortContainer) { return; } const supportsEffort = this.supportsEffortModel(model); effortContainer.style.display = supportsEffort ? 'block' : 'none'; const effortSelect = getElementByIdOrNull('settings-agent-effort'); if (!effortSelect) { return; } const currentEffort = (effortSelect.value || 'medium') as EffortLevel; const normalizedEffort = this.normalizeEffortForModel(model, currentEffort); effortSelect.innerHTML = this.buildEffortOptions(model, normalizedEffort); effortSelect.value = normalizedEffort; } getNormalizedModelForBackend(backend: AgentBackend, model: string): string { const isCodexBackend = backend === 'codex-mcp'; if (!model) { return isCodexBackend ? 'gpt-5.2-codex' : 'claude-sonnet-4-6'; } const isClaudeModel = /^claude-/i.test(model); if (isCodexBackend && isClaudeModel) { return 'gpt-5.2-codex'; } if (backend === 'claude' && !isClaudeModel) { return 'claude-sonnet-4-20250514'; } return model; } /** * Collect tool selection data from checkboxes */ collectToolModeData(): SettingsPayloadToolConfig { const gatewayTools: string[] = []; const mcpTools: string[] = []; // Collect selected Gateway tools document.querySelectorAll('.gateway-tool:checked').forEach((cb) => { gatewayTools.push(cb.value); }); // Collect selected MCP tools document.querySelectorAll('.mcp-tool:checked').forEach((cb) => { mcpTools.push(cb.value); }); // If all Gateway tools are selected, use wildcard const allGateway = document.querySelectorAll('.gateway-tool'); if (gatewayTools.length === allGateway.length && gatewayTools.length > 0) { return { gateway: ['*'], mcp: mcpTools, mcp_config: '~/.mama/mama-mcp-config.json', }; } return { gateway: gatewayTools, mcp: mcpTools, mcp_config: '~/.mama/mama-mcp-config.json', }; } /** * Reset form to current saved values */ resetForm(): void { this.populateForm(); this.setStatus('Form reset'); setTimeout(() => this.setStatus(''), 2000); } /** * Helper: Set checkbox value */ setCheckbox(id: string, checked: boolean): void { const el = getElementByIdOrNull(id); if (el) { el.checked = !!checked; } } /** * Helper: Get checkbox value */ getCheckbox(id: string): boolean { const el = getElementByIdOrNull(id); return el ? el.checked : false; } /** * Helper: Set input value * @param {string} id - Element ID * @param {string} value - Value to set * @param {boolean} isSensitive - If true, treat as sensitive token (keep if masked) */ setValue(id: string, value: string | number, isSensitive = false): void { const el = getElementByIdOrNull(id); if (el) { // For sensitive fields (tokens), preserve placeholder if value is masked const normalized = String(value ?? ''); if (isSensitive && this.isMaskedToken(normalized)) { el.placeholder = normalized; el.value = ''; } else { el.value = normalized; } } } /** * Check if a token is masked (e.g., "***[redacted]***") */ isMaskedToken(token: string | number | undefined): boolean { if (token === undefined || token === null) { return false; } const str = String(token); return str === '***[redacted]***' || (str.startsWith('***[') && str.endsWith(']***')); } /** * Get token value from input, preserving original if input is empty and original was masked * @param {string} id - Input element ID * @param {string} originalToken - Original token value from config * @returns {string} Token to send (either new value or original masked token) */ getTokenValue(id: string, originalToken?: string): string { const inputValue = this.getValue(id); // If user entered a new value, use it if (inputValue && inputValue.trim() !== '') { return inputValue; } // If input is empty and original was masked, keep the masked token (backend will preserve it) if (this.isMaskedToken(originalToken)) { return originalToken; } // Otherwise return the input value (may be empty) return inputValue; } /** * Helper: Get input value */ getValue(id: string): string { const el = getElementByIdOrNull(id); return el ? el.value : ''; } /** * Helper: Set select value */ setSelectValue(id: string, value: string): void { const el = getElementByIdOrNull(id); if (el) { el.value = value; } } /** * Helper: Get select value */ getSelectValue(id: string): string { const el = getElementByIdOrNull(id); return el ? el.value : ''; } /** * Helper: Set radio button */ setRadio(id: string, checked: boolean): void { const el = getElementByIdOrNull(id); if (el) { el.checked = !!checked; } } /** * Helper: Get radio button value */ getRadio(id: string): boolean { const el = getElementByIdOrNull(id); return el ? el.checked : false; } /** * Set status message */ setStatus(message: string, type: SettingsFilterValue = ''): void { const statusEl = getElementByIdOrNull('settings-status'); if (statusEl) { statusEl.textContent = message; statusEl.className = `text-sm ${ type === 'error' ? 'text-red-500' : type === 'success' ? 'text-green-500' : 'text-gray-500' }`; } } /** * Populate Multi-Agent Team section (F3) * Agent editing has moved to the Agents tab — show redirect notice. */ populateMultiAgentSection(): void { const container = getElementByIdOrNull('settings-multi-agent-container'); if (!container) { return; } container.innerHTML = `

Agent management has moved to the Agents tab.

`; } /** * Toggle agent enabled status (F3) */ async toggleAgent(agentId: string, enabled: boolean): Promise { try { await API.put(`/api/multi-agent/agents/${agentId}`, { enabled }); logger.info(`Agent ${agentId} ${enabled ? 'enabled' : 'disabled'}`); } catch (error) { logger.error('Failed to toggle agent:', error); // Revert checkbox on error const checkbox = document.querySelector( `input[data-action="agent-toggle"][data-agent-id="${agentId}"]` ); if (checkbox) { checkbox.checked = !enabled; } alert(`Failed to update agent: ${error instanceof Error ? error.message : String(error)}`); } } onAgentBackendChange(agentId: string): void { const backendSelect = getElementByIdOrNull(`agent-backend-${agentId}`); const modelSelect = getElementByIdOrNull(`agent-model-${agentId}`); if (!backendSelect || !modelSelect) { return; } const backend = (backendSelect.value || 'claude') as AgentBackend; const currentModel = modelSelect.value || ''; const normalized = this.getNormalizedModelForBackend(backend, currentModel); const options = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude; modelSelect.innerHTML = options .map( (m) => `` ) .join(''); this.refreshAgentEffortControls(agentId, normalized); } onAgentModelChange(agentId: string): void { const modelSelect = getElementByIdOrNull(`agent-model-${agentId}`); if (!modelSelect) { return; } const model = modelSelect.value || ''; this.refreshAgentEffortControls(agentId, model); } async saveAgentConfig(agentId: string): Promise { try { const backendSelect = getElementByIdOrNull(`agent-backend-${agentId}`); const modelSelect = getElementByIdOrNull(`agent-model-${agentId}`); const delegateCheckbox = getElementByIdOrNull(`agent-delegate-${agentId}`); const allToolsCheckbox = getElementByIdOrNull(`agent-alltools-${agentId}`); if (!backendSelect || !modelSelect) { throw new Error('Agent settings inputs not found'); } const backend = (backendSelect.value || 'claude') as AgentBackend; const model = this.getNormalizedModelForBackend(backend, modelSelect.value || ''); const can_delegate = delegateCheckbox?.checked ?? false; const hasAllTools = allToolsCheckbox?.checked ?? false; // Effort level const effortSelect = getElementByIdOrNull(`agent-effort-${agentId}`); const supportsEffort = this.supportsEffortModel(model); const effort: EffortLevel | undefined = supportsEffort && effortSelect ? this.normalizeEffortForModel(model, effortSelect.value as EffortLevel) : undefined; // Build tool_permissions based on checkbox const tool_permissions = hasAllTools ? { allowed: ['*'], blocked: [] } : { allowed: ['Read', 'Grep', 'Glob'], blocked: [] }; await API.put(`/api/multi-agent/agents/${agentId}`, { backend, model, effort, can_delegate, tool_permissions, }); showToast(`Saved ${agentId} (applied)`); await this.loadSettings(); } catch (error) { logger.error('Failed to save agent config:', error); alert( `Failed to save agent config: ${error instanceof Error ? error.message : String(error)}` ); } } /** * Populate installed skills section */ async populateSkillsSection(): Promise { const container = getElementByIdOrNull('settings-skills-container'); if (!container) { return; } try { const { skills } = await API.get('/api/skills'); if (!skills || skills.length === 0) { container.innerHTML = '

No skills installed

'; return; } const sourceColors = { mama: 'bg-yellow-100 text-yellow-700', cowork: 'bg-blue-100 text-blue-700', external: 'bg-purple-100 text-purple-700', }; container.innerHTML = `
${skills .map( (s) => `
${escapeHtml(s.name)} ${escapeHtml(s.source)}
` ) .join('')}
`; container.querySelectorAll('input[data-skill-id]').forEach((input) => { input.addEventListener('change', (event) => { const target = event.target; if (!(target instanceof HTMLInputElement)) { return; } const source = target.dataset.skillSource || ''; const id = target.dataset.skillId || ''; if (!source || !id) { return; } this.toggleSkill(source, id, target.checked); }); }); } catch (error) { logger.warn('Skills load error:', error instanceof Error ? error.message : String(error)); container.innerHTML = '

Failed to load skills

'; } } /** * Toggle skill enabled/disabled from settings */ async toggleSkill(source: string, name: string, enabled: boolean): Promise { try { await API.toggleSkill(name, enabled, source); } catch (error) { logger.error('Skill toggle failed:', error instanceof Error ? error.message : String(error)); this.populateSkillsSection(); } } /** * Populate scheduled jobs section */ async populateCronSection(): Promise { const container = getElementByIdOrNull('settings-cron-container'); if (!container) { return; } try { const { jobs } = await API.get('/api/cron'); if (!jobs || jobs.length === 0) { container.innerHTML = '

No scheduled jobs

'; return; } const cronJobs = jobs as CronJob[]; container.innerHTML = `
${cronJobs .map((job) => { const nextRun = job.nextRun ? new Date(job.nextRun).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }) : '-'; return `
${escapeHtml(job.name || job.id)} ${escapeHtml(job.schedule || job.cronExpr || '')}

${escapeHtml((job.prompt || '').slice(0, 80))}

${nextRun}
`; }) .join('')}
`; } catch (error) { logger.warn('Cron load error:', error); container.innerHTML = '

Failed to load jobs

'; } } async addCronJob(): Promise { const nameInput = getElementByIdOrNull('settings-cron-name'); const cronExprInput = getElementByIdOrNull('settings-cron-expr'); const promptInput = getElementByIdOrNull('settings-cron-prompt'); if (!nameInput || !cronExprInput || !promptInput) { return; } const name = nameInput.value.trim(); const cronExpr = cronExprInput.value.trim(); const prompt = promptInput.value.trim(); if (!name || !cronExpr || !prompt) { showToast('Please fill in all fields'); return; } try { await API.post('/api/cron', { name, cron_expr: cronExpr, prompt }); nameInput.value = ''; cronExprInput.value = ''; promptInput.value = ''; showToast('Job created'); this.populateCronSection(); } catch (error) { showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`); } } async toggleCronJob(id: string, enabled: boolean): Promise { try { await API.updateCronJob(id, { enabled }); } catch (error) { logger.error('Cron toggle failed:', error); this.populateCronSection(); } } async deleteCronJob(id: string): Promise { if (!confirm('Delete this scheduled job?')) { return; } try { await API.del(`/api/cron/${encodeURIComponent(id)}`); showToast('Job deleted'); this.populateCronSection(); } catch (error) { showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Populate metrics section from config */ populateMetricsSection(): void { const metrics = this.config?.metrics; this.setCheckbox('settings-metrics-enabled', metrics?.enabled !== false); this.setValue('settings-metrics-retention', metrics?.retention_days ?? 7); } /** * Populate token budget section from config */ populateTokenSection(): void { const budget = this.config?.token_budget; if (!budget) { return; } this.setValue('settings-token-daily-limit', budget.daily_limit || ''); this.setValue('settings-token-alert-threshold', budget.alert_threshold || ''); } }