From ea3e5346076ea93bd3a6f9f36f1ee35c63e8e3ed Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 16 Feb 2026 21:19:16 +0100 Subject: [PATCH] chore: bump version to 0.1520 --- CLAUDE.md | 2 +- mobile-test.mjs | 174 +++++++++++++++++++++ package.json | 2 +- src/web/public/app.js | 132 +++++++++------- src/web/public/index.html | 2 +- src/web/public/mobile.css | 307 ++++++++++++++++++++++++++++++++++++++ src/web/server.ts | 41 +++++ 7 files changed, 601 insertions(+), 59 deletions(-) create mode 100644 mobile-test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index bb737c2f..628902c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1519 (must match `package.json` for npm publish) +**Version**: 0.1520 (must match `package.json` for npm publish) ## Project Overview diff --git a/mobile-test.mjs b/mobile-test.mjs new file mode 100644 index 00000000..ec18e851 --- /dev/null +++ b/mobile-test.mjs @@ -0,0 +1,174 @@ +import { chromium } from 'playwright'; + +const BASE = 'http://localhost:3000'; + +// iPhone 14 Pro viewport +const MOBILE_VP = { width: 393, height: 852 }; +// iPad viewport +const TABLET_VP = { width: 768, height: 1024 }; + +async function testMobile(page, label, vp) { + await page.setViewportSize(vp); + await page.goto(BASE, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + const results = []; + + // 1. Check settings modal renders and tabs are visible + // Open settings via mobile toolbar button or header button + const settingsBtn = await page.$('.btn-settings-mobile') || await page.$('.btn-settings'); + if (settingsBtn) { + await settingsBtn.click(); + await page.waitForTimeout(500); + + const modal = await page.$('#appSettingsModal.active'); + results.push({ test: `${label}: Settings modal opens`, pass: !!modal }); + + if (modal) { + // Check tab buttons are visible and not overflowing + const tabs = await page.$$('#appSettingsModal .modal-tab-btn'); + results.push({ test: `${label}: Settings has ${tabs.length} tabs`, pass: tabs.length === 5 }); + + // Check first tab (Display) content is visible + const displayTab = await page.$('#settings-display'); + const displayVisible = displayTab ? await displayTab.isVisible() : false; + results.push({ test: `${label}: Display tab content visible`, pass: displayVisible }); + + // Check settings-grid layout + const settingsGrid = await page.$('#settings-display .settings-grid'); + if (settingsGrid) { + const gridStyle = await settingsGrid.evaluate(el => { + const cs = window.getComputedStyle(el); + return cs.gridTemplateColumns; + }); + const isSingleCol = vp.width < 430; + if (isSingleCol) { + // On phone, should be single column + const colCount = gridStyle.split(' ').length; + results.push({ test: `${label}: Settings grid is single-col (${colCount} cols)`, pass: colCount === 1 }); + } else { + results.push({ test: `${label}: Settings grid columns = ${gridStyle}`, pass: true }); + } + } + + // Switch to notifications tab and check event grid + const notifTab = await page.$('[data-tab="settings-notifications"]'); + if (notifTab) { + await notifTab.click(); + await page.waitForTimeout(300); + const eventGrid = await page.$('.event-type-grid'); + if (eventGrid) { + const eventGridVisible = await eventGrid.isVisible(); + results.push({ test: `${label}: Event type grid visible`, pass: eventGridVisible }); + + const gridCols = await eventGrid.evaluate(el => { + return window.getComputedStyle(el).gridTemplateColumns; + }); + results.push({ test: `${label}: Event grid cols = ${gridCols}`, pass: true }); + } + } + + // Check modal tab scrollability + const tabsContainer = await page.$('#appSettingsModal .modal-tabs'); + if (tabsContainer) { + const overflow = await tabsContainer.evaluate(el => { + const cs = window.getComputedStyle(el); + return { overflowX: cs.overflowX, flexWrap: cs.flexWrap }; + }); + results.push({ test: `${label}: Tab bar overflow-x=${overflow.overflowX}, flex-wrap=${overflow.flexWrap}`, pass: overflow.overflowX === 'auto' || overflow.flexWrap === 'nowrap' }); + } + + // Close modal + const closeBtn = await page.$('#appSettingsModal .modal-close'); + if (closeBtn) await closeBtn.click(); + await page.waitForTimeout(300); + } + } else { + results.push({ test: `${label}: Settings button found`, pass: false }); + } + + // 2. Check subagent badge sizing + // We can check the CSS computed style for .tab-subagent-badge if any exist + const badges = await page.$$('.tab-subagent-badge'); + if (badges.length > 0) { + const badgeHeight = await badges[0].evaluate(el => { + return parseFloat(window.getComputedStyle(el).height); + }); + if (vp.width < 430) { + results.push({ test: `${label}: Subagent badge height=${badgeHeight}px (expect ~14)`, pass: badgeHeight <= 16 }); + } else { + results.push({ test: `${label}: Subagent badge height=${badgeHeight}px`, pass: true }); + } + } else { + results.push({ test: `${label}: No subagent badges to check (OK)`, pass: true }); + } + + // 3. Check subagent windows if any are open + const subWindows = await page.$$('.subagent-window'); + if (subWindows.length > 0 && vp.width < 430) { + for (const sw of subWindows) { + const swStyle = await sw.evaluate(el => { + const cs = window.getComputedStyle(el); + return { + position: cs.position, + width: cs.width, + borderRadius: cs.borderRadius, + }; + }); + results.push({ test: `${label}: Subagent window position=${swStyle.position}, width=${swStyle.width}`, pass: swStyle.position === 'fixed' }); + } + } + + // 4. Check header height + const header = await page.$('.header'); + if (header) { + const headerHeight = await header.evaluate(el => { + return parseFloat(window.getComputedStyle(el).height); + }); + const expectedMax = vp.width < 430 ? 40 : 55; + results.push({ test: `${label}: Header height=${headerHeight}px (max ${expectedMax})`, pass: headerHeight <= expectedMax }); + } + + // 5. Check toolbar is fixed at bottom on mobile + if (vp.width < 430) { + const toolbar = await page.$('.toolbar'); + if (toolbar) { + const toolbarPos = await toolbar.evaluate(el => { + return window.getComputedStyle(el).position; + }); + results.push({ test: `${label}: Toolbar position=${toolbarPos}`, pass: toolbarPos === 'fixed' }); + } + } + + return results; +} + +(async () => { + const browser = await chromium.launch({ headless: true }); + + try { + // Test mobile viewport + const mobilePage = await browser.newPage(); + const mobileResults = await testMobile(mobilePage, 'Phone (393px)', MOBILE_VP); + await mobilePage.close(); + + // Test tablet viewport + const tabletPage = await browser.newPage(); + const tabletResults = await testMobile(tabletPage, 'Tablet (768px)', TABLET_VP); + await tabletPage.close(); + + const all = [...mobileResults, ...tabletResults]; + const passed = all.filter(r => r.pass).length; + const failed = all.filter(r => !r.pass).length; + + console.log('\n=== Mobile Optimization Test Results ===\n'); + for (const r of all) { + console.log(` ${r.pass ? '✓' : '✗'} ${r.test}`); + } + console.log(`\n ${passed} passed, ${failed} failed out of ${all.length} tests\n`); + + if (failed > 0) process.exit(1); + } finally { + await browser.close(); + } +})(); diff --git a/package.json b/package.json index 56c172fc..41f824fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1519", + "version": "0.1520", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/web/public/app.js b/src/web/public/app.js index 6dbe0945..40162218 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -9436,7 +9436,7 @@ class ClaudemanApp { // Default model const defaultModelEl = document.getElementById('appSettingsDefaultModel'); if (defaultModelEl) { - defaultModelEl.value = config.defaultModel || 'sonnet'; + defaultModelEl.value = config.defaultModel || 'opus'; } // Show recommendations const showRecsEl = document.getElementById('appSettingsShowModelRecommendations'); @@ -9475,7 +9475,7 @@ class ClaudemanApp { if (reviewEl?.value) agentTypeOverrides.review = reviewEl.value; const config = { - defaultModel: defaultModelEl?.value || 'sonnet', + defaultModel: defaultModelEl?.value || 'opus', showRecommendations: showRecsEl?.checked ?? true, agentTypeOverrides, }; @@ -12286,60 +12286,77 @@ class ClaudemanApp { // Calculate final position - grid layout to avoid overlaps const windowCount = this.subagentWindows.size; - const windowWidth = 420; - const windowHeight = 350; + const isMobile = MobileDetection.getDeviceType() === 'mobile'; + const windowWidth = isMobile ? window.innerWidth : 420; + const windowHeight = isMobile ? Math.round(window.innerHeight * 0.4) : 350; const gap = 20; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - // Check if Ralph wizard modal is open - if so, position windows on the sides - const wizardModal = document.getElementById('ralphWizardModal'); - const wizardOpen = wizardModal?.classList.contains('active'); + let finalX = 0; + let finalY = 0; - let startX, startY, maxCols; - - if (wizardOpen) { - // Wizard is ~720px wide, centered. Position windows on left/right sides - const wizardWidth = 720; - const centerX = viewportWidth / 2; - const wizardLeft = centerX - wizardWidth / 2; - const wizardRight = centerX + wizardWidth / 2; - - // Alternate between left and right sides of the wizard - const leftSideSpace = wizardLeft - 20; - const rightSideSpace = viewportWidth - wizardRight - 20; - - if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) { - // Even windows go to the right - startX = wizardRight + 20; - maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1; - } else if (leftSideSpace >= windowWidth) { - // Odd windows go to the left - startX = Math.max(10, wizardLeft - windowWidth - 20); - maxCols = 1; // Usually only room for 1 column on left - } else { - // Not enough side space, use right side - startX = wizardRight + 20; - maxCols = 1; + if (isMobile) { + // Mobile: CSS handles full-width bottom-sheet positioning via .subagent-window rules. + // Only one window visible at a time on mobile — new window replaces the old. + // Minimize all existing windows first so only the latest is visible. + for (const [existingId, existingData] of this.subagentWindows) { + if (!existingData.minimized) { + this.minimizeSubagentWindow(existingId); + } } - startY = 80; // Start higher when wizard is open + finalX = 0; + finalY = viewportHeight - windowHeight - 40; // toolbar height } else { - // Normal positioning - startX = 50; - startY = 120; - maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; + // Check if Ralph wizard modal is open - if so, position windows on the sides + const wizardModal = document.getElementById('ralphWizardModal'); + const wizardOpen = wizardModal?.classList.contains('active'); + + let startX, startY, maxCols; + + if (wizardOpen) { + // Wizard is ~720px wide, centered. Position windows on left/right sides + const wizardWidth = 720; + const centerX = viewportWidth / 2; + const wizardLeft = centerX - wizardWidth / 2; + const wizardRight = centerX + wizardWidth / 2; + + // Alternate between left and right sides of the wizard + const leftSideSpace = wizardLeft - 20; + const rightSideSpace = viewportWidth - wizardRight - 20; + + if (windowCount % 2 === 0 && rightSideSpace >= windowWidth) { + // Even windows go to the right + startX = wizardRight + 20; + maxCols = Math.floor(rightSideSpace / (windowWidth + gap)) || 1; + } else if (leftSideSpace >= windowWidth) { + // Odd windows go to the left + startX = Math.max(10, wizardLeft - windowWidth - 20); + maxCols = 1; // Usually only room for 1 column on left + } else { + // Not enough side space, use right side + startX = wizardRight + 20; + maxCols = 1; + } + startY = 80; // Start higher when wizard is open + } else { + // Normal positioning + startX = 50; + startY = 120; + maxCols = Math.floor((viewportWidth - startX - 50) / (windowWidth + gap)) || 1; + } + + const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; + const col = windowCount % maxCols; + const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport + finalX = startX + col * (windowWidth + gap); + finalY = startY + row * (windowHeight + gap); + + // Ensure window stays within viewport bounds + finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); + finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); } - const maxRows = Math.floor((viewportHeight - startY - 50) / (windowHeight + gap)) || 1; - const col = windowCount % maxCols; - const row = Math.floor(windowCount / maxCols) % maxRows; // Wrap rows to stay in viewport - let finalX = startX + col * (windowWidth + gap); - let finalY = startY + row * (windowHeight + gap); - - // Ensure window stays within viewport bounds - finalX = Math.max(10, Math.min(finalX, viewportWidth - windowWidth - 10)); - finalY = Math.max(10, Math.min(finalY, viewportHeight - windowHeight - 10)); - // Get parent session from PERSISTENT map (THE source of truth for tab connections) const parentSessionId = this.subagentParentMap.get(agentId) || agent.parentSessionId; let parentSessionName = null; @@ -12378,7 +12395,8 @@ class ClaudemanApp { const teammateInfo = this.getTeammateInfo(agent); const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7)); - const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle; + const maxTitleLen = isMobile ? 30 : 50; + const truncatedTitle = windowTitle.length > maxTitleLen ? windowTitle.substring(0, maxTitleLen) + '...' : windowTitle; const modelBadge = agent.modelShort ? `${agent.modelShort}` : ''; @@ -12401,23 +12419,25 @@ class ClaudemanApp { `; // If we have a parent tab, start window at tab position for spawn animation - if (parentTab) { + // On mobile, skip spawn animation — CSS handles bottom-sheet positioning + if (parentTab && !isMobile) { const tabRect = parentTab.getBoundingClientRect(); win.style.left = `${tabRect.left}px`; win.style.top = `${tabRect.bottom}px`; win.style.transform = 'scale(0.3)'; win.style.opacity = '0'; win.classList.add('spawning'); - } else { - // No parent tab, just position normally + } else if (!isMobile) { + // No parent tab, just position normally (desktop/tablet) win.style.left = `${finalX}px`; win.style.top = `${finalY}px`; } + // On mobile: CSS positions via fixed bottom-sheet rules, no inline positioning needed document.body.appendChild(win); - // Make draggable (returns listener refs for cleanup) - const dragListeners = this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); + // Make draggable (returns listener refs for cleanup) — skip on mobile (bottom-sheet) + const dragListeners = isMobile ? null : this.makeWindowDraggable(win, win.querySelector('.subagent-window-header')); // Check if this window should be visible based on settings // Use the PERSISTENT parent map for accurate tab-based visibility @@ -12467,8 +12487,8 @@ class ClaudemanApp { // Store observer for cleanup this.subagentWindows.get(agentId).resizeObserver = resizeObserver; - // Animate to final position if spawning from tab - if (parentTab) { + // Animate to final position if spawning from tab (desktop only) + if (parentTab && !isMobile) { requestAnimationFrame(() => { win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)'; win.style.left = `${finalX}px`; @@ -12484,7 +12504,7 @@ class ClaudemanApp { }, 400); }); } else { - // No animation, just update connection lines + // No animation (mobile uses CSS positioning), just update connection lines this.updateConnectionLines(); } diff --git a/src/web/public/index.html b/src/web/public/index.html index d296b0cd..30809a60 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -897,8 +897,8 @@
Model used for execution tasks. Optimizer suggestions are advisory only. diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 3043e9c7..e720efb9 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -145,6 +145,74 @@ html.mobile-init .file-browser-panel { max-width: 280px; font-size: 0.7rem; } + + /* ---- Settings Modal: Tablet Optimizations ---- */ + + .modal-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + flex-wrap: nowrap; + } + + .modal-tabs::-webkit-scrollbar { + display: none; + } + + .modal-tab-btn { + padding: 0.4rem 0.75rem; + font-size: 0.7rem; + white-space: nowrap; + flex-shrink: 0; + } + + /* Settings grid stays 2-col on tablet but tighter */ + .settings-grid { + gap: 0.4rem 0.75rem; + } + + .settings-item { + font-size: 0.7rem; + } + + /* Event type grid - slightly tighter */ + .event-type-grid { + grid-template-columns: 1fr 40px 40px 40px; + gap: 5px 6px; + } + + .event-label { + font-size: 0.7rem; + } + + /* ---- Subagent Windows: Tablet Optimizations ---- */ + + .subagent-window { + width: 320px; + height: 280px; + min-width: 240px; + min-height: 160px; + } + + .subagent-window-header { + padding: 0.35rem 0.5rem; + } + + .subagent-window-title .id { + font-size: 0.7rem; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .subagent-window-title .status { + font-size: 0.55rem; + } + + .subagent-window-body { + font-size: 0.7rem; + } } /* ============================================================================ @@ -1022,6 +1090,245 @@ html.mobile-init .file-browser-panel { .mobile-case-picker-footer { padding-bottom: calc(12px + var(--safe-area-bottom)); } + + /* ---- Settings Modal: Mobile Optimizations ---- */ + + /* Scrollable tabs row - prevent overflow on small screens */ + .modal-tabs { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + gap: 0.25rem; + padding: 0 0.75rem 0.5rem 0.75rem; + flex-wrap: nowrap; + } + + .modal-tabs::-webkit-scrollbar { + display: none; + } + + .modal-tab-btn { + padding: 0.35rem 0.6rem; + font-size: 0.65rem; + white-space: nowrap; + flex-shrink: 0; + } + + /* Settings grid - single column on phones */ + .settings-grid { + grid-template-columns: 1fr; + gap: 0.35rem; + } + + .settings-grid-3col { + grid-template-columns: 1fr 1fr 1fr; + } + + .settings-item { + padding: 0.35rem 0.5rem; + font-size: 0.7rem; + } + + .settings-item-label { + font-size: 0.7rem; + } + + .settings-section-header { + font-size: 0.6rem; + padding: 0.35rem 0 0.2rem 0; + margin-top: 0.5rem; + } + + /* Form rows - compact on mobile */ + .form-row { + margin-bottom: 0.5rem; + } + + .form-row label { + font-size: 0.7rem; + margin-bottom: 0.2rem; + } + + .form-row .form-hint { + font-size: 0.6rem; + } + + .form-row input[type="text"], + .form-row input[type="number"], + .form-row .form-select, + .form-row select { + font-size: 0.75rem; + padding: 0.35rem 0.5rem; + min-height: 32px; + } + + .form-row-switch { + gap: 0.3rem; + } + + .form-row-switch > label:first-child { + font-size: 0.7rem; + } + + .form-section-header { + font-size: 0.6rem; + margin-top: 0.75rem; + } + + /* Event type grid - tighter on mobile */ + .event-type-grid { + grid-template-columns: 1fr 36px 36px 36px; + gap: 4px 4px; + padding: 6px; + margin-top: 6px; + } + + .event-header { + font-size: 0.55rem; + } + + .event-label { + font-size: 0.65rem; + } + + .event-type-grid input[type="checkbox"] { + width: 12px; + height: 12px; + } + + /* Modal footer buttons - full width on mobile */ + .form-actions { + gap: 0.5rem; + } + + .form-actions .btn-toolbar { + flex: 1; + min-height: 36px !important; + max-height: 36px !important; + height: 36px !important; + font-size: 0.75rem !important; + } + + /* ---- Subagent Windows: Mobile Optimizations ---- */ + + /* Full-width stacked windows at bottom on phones */ + .subagent-window { + position: fixed !important; + left: 0 !important; + right: 0 !important; + width: 100% !important; + max-width: 100% !important; + height: 40vh !important; + min-height: 150px !important; + max-height: 50vh !important; + bottom: calc(40px + var(--safe-area-bottom)) !important; + top: auto !important; + border-radius: 8px 8px 0 0; + resize: none; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); + transform: none !important; + } + + /* Hide resize handle on mobile */ + .subagent-window::after { + display: none; + } + + /* Compact subagent window header */ + .subagent-window-header { + padding: 0.3rem 0.5rem; + min-height: 32px; + } + + .subagent-window-title { + gap: 0.25rem; + overflow: hidden; + } + + .subagent-window-title .icon { + font-size: 0.8rem; + } + + .subagent-window-title .id { + font-size: 0.65rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 45vw; + } + + .subagent-window-title .status { + font-size: 0.5rem; + padding: 0.05rem 0.2rem; + } + + .subagent-model-badge { + font-size: 0.5rem !important; + padding: 0.05rem 0.2rem !important; + } + + .subagent-window-actions button { + font-size: 0.8rem; + padding: 0.3rem 0.5rem; + min-width: 32px; + min-height: 32px; + display: flex; + align-items: center; + justify-content: center; + } + + .subagent-window-body { + font-size: 0.65rem; + padding: 0.35rem; + } + + .subagent-window-body .activity-line { + gap: 0.2rem; + padding: 0.1rem 0; + } + + .subagent-window-body .activity-line .time { + font-size: 0.55rem; + } + + .subagent-window-body .activity-line .tool-name { + font-size: 0.6rem; + } + + .subagent-window-body .activity-line .tool-detail { + font-size: 0.6rem; + } + + /* Parent label in subagent window - compact */ + .subagent-window-parent { + padding: 0.15rem 0.5rem; + font-size: 0.6rem; + } + + /* ---- Tab Subagent Badges: Smaller on Mobile ---- */ + + .session-tab .tab-subagent-badge { + height: 14px; + padding: 0 3px; + border-radius: 7px; + margin-left: 2px; + } + + .session-tab .tab-subagent-badge .subagent-label { + font-size: 0.45rem; + } + + /* Subagent dropdown - full width bottom sheet on mobile */ + .subagent-dropdown { + position: fixed !important; + left: 8px !important; + right: 8px !important; + bottom: calc(40px + var(--safe-area-bottom) + 8px) !important; + top: auto !important; + min-width: auto !important; + max-width: none !important; + border-radius: 8px; + } } diff --git a/src/web/server.ts b/src/web/server.ts index fcb927a0..a64b3c00 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -3126,6 +3126,47 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } }); + // ============ Model Configuration Endpoints ============ + + this.app.get('/api/execution/model-config', async () => { + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(content); + return { success: true, data: settings.modelConfig || {} }; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('Failed to read model config:', err); + } + return { success: true, data: {} }; + } + }); + + this.app.put('/api/execution/model-config', async (req) => { + const modelConfig = req.body as Record; + + try { + let settings: Record = {}; + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + settings = JSON.parse(content); + } catch { + // File doesn't exist yet, start fresh + } + + settings.modelConfig = modelConfig; + + const dir = dirname(settingsPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + + return { success: true }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + // ============ CPU Priority Endpoints ============ // Get Nice priority config for a session