Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/alsayadi_alaude-desktop.json
T

1 line
2.9 MiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "alsayadi/alaude-desktop", "files": {"electron/main.js": {"c56ff3de23264fe4d74d0616d9e36a625f8b3648": {"t": 1785746586.9503012, "n": 103935, "h": "1657dd2004cb6c94", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n // v0.8 cycle 36: OpenAI turned reasoning into a request parameter, so\n // there are no `-thinking` ids any more — the deep-reasoning SKUs are\n // `gpt-5.x-pro`. Grok's explicit reasoning variants and Hunyuan's\n // agentic hy3-preview think silently for minutes too.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5[\\d.]*-pro|^gpt-5.*think|^grok-.*reasoning|^hy3-preview|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 cycle 32 — Care bundle: a STARTER setup file for someone you love\n// (their language, Easy mode, a couple of caring reminders). Unlike\n// backup-export, this writes a renderer-built bundle that contains NONE\n// of the owner's own data — it's a gift, not a clone.\nipcMain.handle('care-export', async (_e, bundle) => {\n try {\n if (!bundle || bundle.kind !== 'labaik-backup') return { ok: false, reason: 'bad bundle' }\n const res = await dialog.showSaveDialog({\n title: 'Save the setup file',\n defaultPath: 'labaik-care-setup.json',\n filters: [{ name: 'Labaik setup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle, null, 2), 'utf8')\n return { ok: true, path: res.filePath }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nconst modelDiscovery = require('./model-discovery')\nconst watchers = require('./watchers')\n// v0.8 cycle 37: ask the providers the user already pays for what models\n// their key can see, so a model released today is usable today instead of\n// waiting for the next build. Strictly additive to the curated picker —\n// see electron/model-discovery.js for why there is no remote manifest.\nipcMain.handle('models-discover', async (_e, opts) => {\n try {\n const providers = PROVIDER_KEY_IDS.filter(p => !!getApiKey(p))\n if (!providers.length) return { models: {}, fetched: [], cached: [] }\n return await modelDiscovery.discoverAll(providers, getApiKey, {\n force: !!opts?.force,\n // Receipts: every real request lands in the ledger, same as chat.\n onCall: (provider) => {\n let host = provider\n try { host = new URL(getBaseURL(provider) || 'https://api.anthropic.com').host } catch {}\n if (provider === 'google') host = 'generativelanguage.googleapis.com'\n netLedger.log(host, 'model list', provider)\n },\n })\n } catch (err) {\n return { models: {}, fetched: [], cached: [], error: String(err?.message || err).slice(0, 200) }\n }\n})\n\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // v0.8 cycle 24 — watcher pre-check: fetch the watched page, diff its\n // text against the snapshot, and only let the routine proceed (and\n // notify) on a REAL change. Quiet \"no change\" runs land in history\n // only. Returns null to skip, or a context string to prepend.\n async function checkWatchedPage(routine) {\n const url = routine.watch?.url || ''\n try {\n if (!/^https:\\/\\//i.test(url)) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: '👁 watch URL must start with https://' })\n return null\n }\n netLedger.log(netLedger.hostOf(url), 'watcher check')\n const res = await fetch(url, {\n redirect: 'follow',\n signal: AbortSignal.timeout(15000),\n headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) Labaik' },\n })\n if (!res.ok) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: `👁 watch fetch HTTP ${res.status}` })\n return null\n }\n let html = await res.text()\n if (html.length > 3 * 1024 * 1024) html = html.slice(0, 3 * 1024 * 1024)\n const text = watchers.pageText(html)\n const chk = watchers.checkChange(routine.id, text)\n if (chk.first) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 baseline saved — will notify when the page changes' })\n return null\n }\n if (!chk.changed) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 no change' })\n return null\n }\n return `The watched page (${url}) CHANGED since the last check.\\n\\nBEFORE (excerpt):\\n${chk.prevHead}\\n\\nNOW (excerpt):\\n${text.slice(0, 1500)}\\n\\nTask: `\n } catch (err) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: ('👁 ' + (err?.message || 'watch failed')).slice(0, 400) })\n return null\n }\n }\n\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n // Watchers: skip quietly unless the page really changed.\n let watchContext = ''\n if (routine.watch?.url) {\n const ctx = await checkWatchedPage(routine)\n if (ctx === null) return\n watchContext = ctx\n }\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: watchContext + routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "62659926fe916bacd787e3009d42ef1f8ee97242": {"t": 1785765994.0713627, "n": 103935, "h": "1657dd2004cb6c94", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n // v0.8 cycle 36: OpenAI turned reasoning into a request parameter, so\n // there are no `-thinking` ids any more — the deep-reasoning SKUs are\n // `gpt-5.x-pro`. Grok's explicit reasoning variants and Hunyuan's\n // agentic hy3-preview think silently for minutes too.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5[\\d.]*-pro|^gpt-5.*think|^grok-.*reasoning|^hy3-preview|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 cycle 32 — Care bundle: a STARTER setup file for someone you love\n// (their language, Easy mode, a couple of caring reminders). Unlike\n// backup-export, this writes a renderer-built bundle that contains NONE\n// of the owner's own data — it's a gift, not a clone.\nipcMain.handle('care-export', async (_e, bundle) => {\n try {\n if (!bundle || bundle.kind !== 'labaik-backup') return { ok: false, reason: 'bad bundle' }\n const res = await dialog.showSaveDialog({\n title: 'Save the setup file',\n defaultPath: 'labaik-care-setup.json',\n filters: [{ name: 'Labaik setup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle, null, 2), 'utf8')\n return { ok: true, path: res.filePath }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nconst modelDiscovery = require('./model-discovery')\nconst watchers = require('./watchers')\n// v0.8 cycle 37: ask the providers the user already pays for what models\n// their key can see, so a model released today is usable today instead of\n// waiting for the next build. Strictly additive to the curated picker —\n// see electron/model-discovery.js for why there is no remote manifest.\nipcMain.handle('models-discover', async (_e, opts) => {\n try {\n const providers = PROVIDER_KEY_IDS.filter(p => !!getApiKey(p))\n if (!providers.length) return { models: {}, fetched: [], cached: [] }\n return await modelDiscovery.discoverAll(providers, getApiKey, {\n force: !!opts?.force,\n // Receipts: every real request lands in the ledger, same as chat.\n onCall: (provider) => {\n let host = provider\n try { host = new URL(getBaseURL(provider) || 'https://api.anthropic.com').host } catch {}\n if (provider === 'google') host = 'generativelanguage.googleapis.com'\n netLedger.log(host, 'model list', provider)\n },\n })\n } catch (err) {\n return { models: {}, fetched: [], cached: [], error: String(err?.message || err).slice(0, 200) }\n }\n})\n\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // v0.8 cycle 24 — watcher pre-check: fetch the watched page, diff its\n // text against the snapshot, and only let the routine proceed (and\n // notify) on a REAL change. Quiet \"no change\" runs land in history\n // only. Returns null to skip, or a context string to prepend.\n async function checkWatchedPage(routine) {\n const url = routine.watch?.url || ''\n try {\n if (!/^https:\\/\\//i.test(url)) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: '👁 watch URL must start with https://' })\n return null\n }\n netLedger.log(netLedger.hostOf(url), 'watcher check')\n const res = await fetch(url, {\n redirect: 'follow',\n signal: AbortSignal.timeout(15000),\n headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) Labaik' },\n })\n if (!res.ok) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: `👁 watch fetch HTTP ${res.status}` })\n return null\n }\n let html = await res.text()\n if (html.length > 3 * 1024 * 1024) html = html.slice(0, 3 * 1024 * 1024)\n const text = watchers.pageText(html)\n const chk = watchers.checkChange(routine.id, text)\n if (chk.first) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 baseline saved — will notify when the page changes' })\n return null\n }\n if (!chk.changed) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 no change' })\n return null\n }\n return `The watched page (${url}) CHANGED since the last check.\\n\\nBEFORE (excerpt):\\n${chk.prevHead}\\n\\nNOW (excerpt):\\n${text.slice(0, 1500)}\\n\\nTask: `\n } catch (err) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: ('👁 ' + (err?.message || 'watch failed')).slice(0, 400) })\n return null\n }\n }\n\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n // Watchers: skip quietly unless the page really changed.\n let watchContext = ''\n if (routine.watch?.url) {\n const ctx = await checkWatchedPage(routine)\n if (ctx === null) return\n watchContext = ctx\n }\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: watchContext + routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "53439d6d6636d1c23a8c74c7f13acf8b8e810246": {"t": 1785765995.966571, "n": 102786, "h": "77d53845645852ed", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n // v0.8 cycle 36: OpenAI turned reasoning into a request parameter, so\n // there are no `-thinking` ids any more — the deep-reasoning SKUs are\n // `gpt-5.x-pro`. Grok's explicit reasoning variants and Hunyuan's\n // agentic hy3-preview think silently for minutes too.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5[\\d.]*-pro|^gpt-5.*think|^grok-.*reasoning|^hy3-preview|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 cycle 32 — Care bundle: a STARTER setup file for someone you love\n// (their language, Easy mode, a couple of caring reminders). Unlike\n// backup-export, this writes a renderer-built bundle that contains NONE\n// of the owner's own data — it's a gift, not a clone.\nipcMain.handle('care-export', async (_e, bundle) => {\n try {\n if (!bundle || bundle.kind !== 'labaik-backup') return { ok: false, reason: 'bad bundle' }\n const res = await dialog.showSaveDialog({\n title: 'Save the setup file',\n defaultPath: 'labaik-care-setup.json',\n filters: [{ name: 'Labaik setup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle, null, 2), 'utf8')\n return { ok: true, path: res.filePath }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nconst watchers = require('./watchers')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // v0.8 cycle 24 — watcher pre-check: fetch the watched page, diff its\n // text against the snapshot, and only let the routine proceed (and\n // notify) on a REAL change. Quiet \"no change\" runs land in history\n // only. Returns null to skip, or a context string to prepend.\n async function checkWatchedPage(routine) {\n const url = routine.watch?.url || ''\n try {\n if (!/^https:\\/\\//i.test(url)) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: '👁 watch URL must start with https://' })\n return null\n }\n netLedger.log(netLedger.hostOf(url), 'watcher check')\n const res = await fetch(url, {\n redirect: 'follow',\n signal: AbortSignal.timeout(15000),\n headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) Labaik' },\n })\n if (!res.ok) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: `👁 watch fetch HTTP ${res.status}` })\n return null\n }\n let html = await res.text()\n if (html.length > 3 * 1024 * 1024) html = html.slice(0, 3 * 1024 * 1024)\n const text = watchers.pageText(html)\n const chk = watchers.checkChange(routine.id, text)\n if (chk.first) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 baseline saved — will notify when the page changes' })\n return null\n }\n if (!chk.changed) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 no change' })\n return null\n }\n return `The watched page (${url}) CHANGED since the last check.\\n\\nBEFORE (excerpt):\\n${chk.prevHead}\\n\\nNOW (excerpt):\\n${text.slice(0, 1500)}\\n\\nTask: `\n } catch (err) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: ('👁 ' + (err?.message || 'watch failed')).slice(0, 400) })\n return null\n }\n }\n\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n // Watchers: skip quietly unless the page really changed.\n let watchContext = ''\n if (routine.watch?.url) {\n const ctx = await checkWatchedPage(routine)\n if (ctx === null) return\n watchContext = ctx\n }\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: watchContext + routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "ed7a4277d37ad54e743b6af76207a500022c39c3": {"t": 1785765997.6604216, "n": 102460, "h": "8c27deeceeec4f80", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 cycle 32 — Care bundle: a STARTER setup file for someone you love\n// (their language, Easy mode, a couple of caring reminders). Unlike\n// backup-export, this writes a renderer-built bundle that contains NONE\n// of the owner's own data — it's a gift, not a clone.\nipcMain.handle('care-export', async (_e, bundle) => {\n try {\n if (!bundle || bundle.kind !== 'labaik-backup') return { ok: false, reason: 'bad bundle' }\n const res = await dialog.showSaveDialog({\n title: 'Save the setup file',\n defaultPath: 'labaik-care-setup.json',\n filters: [{ name: 'Labaik setup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle, null, 2), 'utf8')\n return { ok: true, path: res.filePath }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nconst watchers = require('./watchers')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // v0.8 cycle 24 — watcher pre-check: fetch the watched page, diff its\n // text against the snapshot, and only let the routine proceed (and\n // notify) on a REAL change. Quiet \"no change\" runs land in history\n // only. Returns null to skip, or a context string to prepend.\n async function checkWatchedPage(routine) {\n const url = routine.watch?.url || ''\n try {\n if (!/^https:\\/\\//i.test(url)) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: '👁 watch URL must start with https://' })\n return null\n }\n netLedger.log(netLedger.hostOf(url), 'watcher check')\n const res = await fetch(url, {\n redirect: 'follow',\n signal: AbortSignal.timeout(15000),\n headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) Labaik' },\n })\n if (!res.ok) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: `👁 watch fetch HTTP ${res.status}` })\n return null\n }\n let html = await res.text()\n if (html.length > 3 * 1024 * 1024) html = html.slice(0, 3 * 1024 * 1024)\n const text = watchers.pageText(html)\n const chk = watchers.checkChange(routine.id, text)\n if (chk.first) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 baseline saved — will notify when the page changes' })\n return null\n }\n if (!chk.changed) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 no change' })\n return null\n }\n return `The watched page (${url}) CHANGED since the last check.\\n\\nBEFORE (excerpt):\\n${chk.prevHead}\\n\\nNOW (excerpt):\\n${text.slice(0, 1500)}\\n\\nTask: `\n } catch (err) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: ('👁 ' + (err?.message || 'watch failed')).slice(0, 400) })\n return null\n }\n }\n\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n // Watchers: skip quietly unless the page really changed.\n let watchContext = ''\n if (routine.watch?.url) {\n const ctx = await checkWatchedPage(routine)\n if (ctx === null) return\n watchContext = ctx\n }\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: watchContext + routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "cd26f9fd57ea753d971a6996d04d9e748cf5ec68": {"t": 1785765999.7249858, "n": 101549, "h": "79583a7577970788", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nconst watchers = require('./watchers')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // v0.8 cycle 24 — watcher pre-check: fetch the watched page, diff its\n // text against the snapshot, and only let the routine proceed (and\n // notify) on a REAL change. Quiet \"no change\" runs land in history\n // only. Returns null to skip, or a context string to prepend.\n async function checkWatchedPage(routine) {\n const url = routine.watch?.url || ''\n try {\n if (!/^https:\\/\\//i.test(url)) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: '👁 watch URL must start with https://' })\n return null\n }\n netLedger.log(netLedger.hostOf(url), 'watcher check')\n const res = await fetch(url, {\n redirect: 'follow',\n signal: AbortSignal.timeout(15000),\n headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh) Labaik' },\n })\n if (!res.ok) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: `👁 watch fetch HTTP ${res.status}` })\n return null\n }\n let html = await res.text()\n if (html.length > 3 * 1024 * 1024) html = html.slice(0, 3 * 1024 * 1024)\n const text = watchers.pageText(html)\n const chk = watchers.checkChange(routine.id, text)\n if (chk.first) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 baseline saved — will notify when the page changes' })\n return null\n }\n if (!chk.changed) {\n routines.recordRun(routine.id, { status: 'ok', resultPreview: '👁 no change' })\n return null\n }\n return `The watched page (${url}) CHANGED since the last check.\\n\\nBEFORE (excerpt):\\n${chk.prevHead}\\n\\nNOW (excerpt):\\n${text.slice(0, 1500)}\\n\\nTask: `\n } catch (err) {\n routines.recordRun(routine.id, { status: 'error', resultPreview: ('👁 ' + (err?.message || 'watch failed')).slice(0, 400) })\n return null\n }\n }\n\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n // Watchers: skip quietly unless the page really changed.\n let watchContext = ''\n if (routine.watch?.url) {\n const ctx = await checkWatchedPage(routine)\n if (ctx === null) return\n watchContext = ctx\n }\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: watchContext + routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "0ed970eaf33b6014659d4caf6d59b4bfc6e1a2c0": {"t": 1785766001.8298342, "n": 99337, "h": "563c68cd97d95213", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification, clipboard } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 23 — share-as-image: render a clean card offscreen, capture\n// it, and put the PNG on the clipboard. Family group chats run on\n// pictures, not markdown exports.\nipcMain.handle('share-image', async (_e, html) => {\n let win\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to share' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Too large to share as an image' }\n win = new BrowserWindow({\n show: false, width: 520, height: 400,\n webPreferences: { sandbox: true, contextIsolation: true, offscreen: true },\n })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n // Size the window to the rendered content (capped) so the capture\n // has no dead space.\n const h = await win.webContents.executeJavaScript('document.body.scrollHeight')\n win.setContentSize(520, Math.max(120, Math.min(2400, Math.ceil(h) + 2)))\n await new Promise(r => setTimeout(r, 150)) // let offscreen repaint settle\n const img = await win.webContents.capturePage()\n if (img.isEmpty()) return { error: 'Capture came back empty' }\n clipboard.writeImage(img)\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Share failed' }\n } finally {\n try { win?.destroy() } catch {}\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "65351eeed20017990bada0d6c627a316fdf495fd": {"t": 1785766003.613048, "n": 98019, "h": "968f6051ca116019", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine, meta = {}) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n // v0.8 cycle 21 — catch-up runs are labeled so the history\n // explains WHY this fired off-schedule (machine was asleep).\n routines.recordRun(routine.id, { status: 'ok', resultPreview: (meta.catchUp ? '↺ catch-up · ' : '') + preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "4dd39e79cf5f9b5c2e9f937949655773be612d68": {"t": 1785766005.524171, "n": 97822, "h": "94e5ab6b54512d13", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n netLedger.log('github.com', 'Ollama installer download')\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 16 — Receipts: the network ledger behind \"Your data\".\nipcMain.handle('net-ledger-recent', async (_e, n) => netLedger.recent(Math.min(200, Math.max(1, n || 50))))\nipcMain.handle('net-ledger-clear', async () => { netLedger.clear(); return { ok: true } })\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nconst netLedger = require('./net-ledger')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n // Receipts: voice recordings leaving the machine are ledger-visible.\n const backend = voice.pickBackend(getApiKey)\n if (backend === 'openai') netLedger.log('api.openai.com', 'voice transcription')\n else if (backend === 'google') netLedger.log('generativelanguage.googleapis.com', 'voice transcription')\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "3acdc32208dad7a6c5281f8bb63852af229fbeac": {"t": 1785766007.4904904, "n": 97136, "h": "108f95481af2c7ab", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 12 — print a clean HTML document (Paperwork reply letters).\n// A hidden window loads the static markup and opens the native print\n// dialog (which includes save-as-PDF on macOS). The window is destroyed\n// after the dialog resolves either way. Size-capped: letters are small.\nipcMain.handle('print-html', async (_e, html) => {\n try {\n if (typeof html !== 'string' || !html.trim()) return { error: 'Nothing to print' }\n if (html.length > 2 * 1024 * 1024) return { error: 'Document too large to print' }\n const win = new BrowserWindow({ show: false, webPreferences: { sandbox: true, contextIsolation: true } })\n await win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(html))\n win.webContents.print({ silent: false, printBackground: true }, () => {\n try { win.destroy() } catch {}\n })\n return { ok: true }\n } catch (err) {\n return { error: err?.message || 'Print failed' }\n }\n})\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "9a0b0ef40d8878024ec16f5435fc445cc7152845": {"t": 1785766009.248223, "n": 96198, "h": "2b8601cc5ed196ff", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n // v0.8 cycle 10 — kitchen-mode dictation: ⌘⇧Space from ANYWHERE brings\n // Labaik up listening; pressing it again stops, transcribes, and sends.\n // globalShortcut has no key-up events, so this is a press-to-start /\n // press-to-send toggle rather than true hold-to-talk. The renderer\n // decides whether voice is actually capable (key present) and toasts\n // honestly when it isn't.\n try {\n const pttAccel = process.platform === 'darwin' ? 'Cmd+Shift+Space' : 'Ctrl+Shift+Space'\n const ok = globalShortcut.register(pttAccel, () => {\n try {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n mainWindow.show()\n mainWindow.focus()\n mainWindow.webContents.send('global-ptt')\n } catch (err) { console.warn('[global-ptt]', err.message) }\n })\n if (!ok) console.warn('[shortcut] global dictation hotkey already taken — skipped')\n } catch (err) { console.warn('[shortcut] ptt register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "ca6fb04f489750fd1825c32e83c82d0f165e5133": {"t": 1785766010.9680827, "n": 95224, "h": "f5e7061a6f8e2275", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\n// v0.8 cycle 6 — voice dictation. Renderer records mic audio (webm/opus)\n// and ships it here; electron/voice.js routes to the best STT backend\n// (openai whisper → gemini → on-device, by key availability). Base64 is\n// 4/3 of raw, so the wire cap is MAX_AUDIO_BYTES * 4/3.\nconst voice = require('./voice')\nipcMain.handle('voice-transcribe', async (_e, payload) => {\n try {\n if (!payload?.audioB64) return { error: 'empty-audio' }\n if (payload.audioB64.length > Math.ceil(voice.MAX_AUDIO_BYTES * 4 / 3)) return { error: 'too-large' }\n return await voice.transcribe({\n buffer: Buffer.from(payload.audioB64, 'base64'),\n mime: payload.mime || 'audio/webm',\n lang: payload.lang,\n getApiKey,\n })\n } catch (err) {\n return { error: err?.message || 'transcribe-failed' }\n }\n})\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "832b5ebd92cfe025fa3d3e8984df9664389b621c": {"t": 1785766012.8429005, "n": 94415, "h": "8450244f3bcc5315", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\n// v0.8 cycle 5 — one-click undo of agent file changes. The worker records\n// pre-images per chat turn (undo-snapshots.js); these expose them to the\n// renderer's \"Undo\" chip and the ⌘K action.\nconst undoSnaps = require('./undo-snapshots')\nipcMain.handle('undo-list-turns', async () => undoSnaps.listTurns())\nipcMain.handle('undo-restore-turn', async (_e, turnId) => undoSnaps.restoreTurn(turnId))\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "0a21f2646e5699f81194411ccee7c5499b274101": {"t": 1785766014.6112669, "n": 94016, "h": "c9073bb82e7d75d1", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\n// v0.8 zero-key first-run: hardware facts for the \"fits your Mac\" pills.\n// Apple Silicon (arm64) runs Q4 GGUF in unified memory; Intel falls back\n// to CPU-only inference, which the renderer treats as one tier slower.\nipcMain.handle('system-info', async () => ({\n totalMemGb: Math.round(os.totalmem() / (1024 ** 3)),\n arch: process.arch,\n platform: process.platform,\n}))\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "55e6ed362f86b8e1e2ee1deb7b041f760c13ec8a": {"t": 1785766016.4225035, "n": 93639, "h": "7a8b0a1ef16a049b", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n // The boot beacon (scripts/test-boot.mjs watches for it) rides the\n // warn/error channel to reach stderr, but it is NOT an error — log it\n // plainly so it doesn't masquerade as one in the dev console.\n if (typeof message === 'string' && message.includes('[boot] main script completed')) {\n try { process.stderr.write(`${message}\\n`) } catch {}\n return\n }\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "94139dac239298a94895379d7ae0ce4672baac77": {"t": 1785766018.179381, "n": 93255, "h": "af90562454c17aa2", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 26: clear one store. Renames the file to .cleared-<ts> rather\n// than hard-deleting, so an accidental clear is recoverable from the data\n// folder. Allowed keys are pinned to the inventory — no arbitrary paths.\nconst CLEARABLE = new Set(['sessions.json', 'memory.json', 'profile.json', 'routines.json', 'routine-history.ndjson', 'spaces.json', 'events.ndjson', 'images', 'skills'])\nipcMain.handle('data-clear', (_e, key) => {\n if (!CLEARABLE.has(key)) return { ok: false, reason: 'not clearable' }\n const fs = require('fs')\n const fp = require('path').join(paths.BASE_DIR, key)\n try {\n if (!fs.existsSync(fp)) return { ok: true, note: 'already empty' }\n fs.renameSync(fp, `${fp}.cleared-${Date.now().toString(36)}`)\n return { ok: true }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "b6d4e11f2d0b66607a6655bd2bb0e099d27b8506": {"t": 1785766020.2176218, "n": 92419, "h": "a8bd839fc54db547", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 25: \"Your data\" — reinforce the private wedge by showing\n// exactly what Labaik keeps on this Mac, with sizes. Read-only inventory;\n// reveal/clear go through existing show-in-folder / per-store handlers.\nipcMain.handle('data-inventory', () => {\n const fs = require('fs')\n const items = [\n { key: 'sessions.json', label: 'Conversations' },\n { key: 'memory.json', label: 'Memory' },\n { key: 'profile.json', label: 'Profile' },\n { key: 'routines.json', label: 'Routines' },\n { key: 'routine-history.ndjson', label: 'Routine run log' },\n { key: 'spaces.json', label: 'Spaces' },\n { key: 'credentials.json', label: 'API keys (never leave this Mac)' },\n { key: 'events.ndjson', label: 'Local usage events' },\n { key: 'images', label: 'Generated images', dir: true },\n { key: 'skills', label: 'Folder skills', dir: true },\n ]\n const dirSize = (d) => {\n let total = 0\n try {\n for (const e of fs.readdirSync(d, { withFileTypes: true })) {\n const fp = require('path').join(d, e.name)\n try { total += e.isDirectory() ? dirSize(fp) : fs.statSync(fp).size } catch {}\n }\n } catch {}\n return total\n }\n const out = []\n for (const it of items) {\n const fp = require('path').join(paths.BASE_DIR, it.key)\n let bytes = 0, exists = false\n try {\n const st = fs.statSync(fp)\n exists = true\n bytes = it.dir ? dirSize(fp) : st.size\n } catch {}\n out.push({ ...it, bytes, exists })\n }\n return { baseDir: paths.BASE_DIR, items: out }\n})\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "fdee1085d498ce4e71d1e3cfdc712fcbf9152322": {"t": 1785766021.9280682, "n": 90889, "h": "4ba8d328eb1bf819", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (routine.notify !== false && Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "0e45ab4ad466ed512cf740a282910d713bedfc68": {"t": 1785766024.0509028, "n": 90833, "h": "ee5fc2a7b2b06249", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 cycle 22: backup & restore. One portable JSON bundle (sessions,\n// memory, profile, routines, spaces, skills) — credentials excluded by\n// design. Import backs up every file it overwrites.\nipcMain.handle('backup-export', async (_e, rendererExtras) => {\n const backup = require('./backup')\n const res = await dialog.showSaveDialog({\n title: 'Save Labaik backup',\n defaultPath: `labaik-backup-${new Date().toISOString().slice(0, 10)}.json`,\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n })\n if (res.canceled || !res.filePath) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = backup.exportBundle(rendererExtras)\n require('fs').writeFileSync(res.filePath, JSON.stringify(bundle), 'utf8')\n return { ok: true, path: res.filePath, files: Object.keys(bundle.files).length, skills: bundle.skills.length }\n } catch (err) {\n return { ok: false, reason: err.message }\n }\n})\nipcMain.handle('backup-import', async () => {\n const backup = require('./backup')\n const res = await dialog.showOpenDialog({\n title: 'Pick a Labaik backup file',\n filters: [{ name: 'Labaik backup', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n try {\n const bundle = JSON.parse(require('fs').readFileSync(res.filePaths[0], 'utf8'))\n return backup.importBundle(bundle)\n } catch (err) {\n return { ok: false, reason: 'Could not read backup: ' + err.message }\n }\n})\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "98a1bec4e0b4c71f166ea2702e78d273cf129310": {"t": 1785766025.798967, "n": 89321, "h": "be8a7ddd6f2792a1", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const researchMode = !!uxMeta?.researchMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode, researchMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "928fedd7683016373aaf9cfe707f10df3ec1f458": {"t": 1785766027.5409093, "n": 89259, "h": "fc01bd10cacc0377", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => {\n try {\n mainWindow?.show(); mainWindow?.focus()\n // v0.8 cycle 18: land directly on the full result.\n mainWindow?.webContents?.send('open-routines-log')\n } catch {}\n })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "0f6f81d50d5048b8ef5cac56375dc0099bbdb4b5": {"t": 1785766029.4983273, "n": 89044, "h": "f6216347269c33a8", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n // Extracted so both the cron scheduler and the \"Run now\" IPC share the\n // exact same fire path (v0.8 cycle 13).\n async function fireRoutine(routine) {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => { try { mainWindow?.show(); mainWindow?.focus() } catch {} })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n }\n routines.startScheduler(fireRoutine)\n // v0.8 cycle 13: run a routine immediately (the ▶ button in the modal).\n ipcMain.handle('routines-run-now', (_e, id) => {\n const r = routines.list().find((x) => x.id === id)\n if (!r) return false\n fireRoutine(r).catch((err) => console.warn('[routines] run-now failed:', err.message))\n return true\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "56a881fd915b0991f747add11a518cbca32f7b05": {"t": 1785766031.0822327, "n": 88558, "h": "c5659466c4d960e5", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\nipcMain.handle('routines-history', (_e, limit) => routines.history(limit))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => { try { mainWindow?.show(); mainWindow?.focus() } catch {} })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "11c90a4aaa0cd38934a8a76411fb066bf6f104dd": {"t": 1785766033.0013485, "n": 88483, "h": "d03aeb8baa7eadf1", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: ChatGPT history import. Parses conversations.json from\n// ChatGPT's data export (Settings → Data controls → Export) and converts\n// each conversation to a plain {title, createdAt, messages[]} the renderer\n// merges into its sessions. The mapping is a node tree; we walk parent\n// links from current_node and reverse — the standard linearization.\nipcMain.handle('import-chatgpt', async () => {\n const fs = require('fs')\n const res = await dialog.showOpenDialog({\n title: 'Pick conversations.json from your ChatGPT export',\n filters: [{ name: 'ChatGPT export', extensions: ['json'] }],\n properties: ['openFile'],\n })\n if (res.canceled || !res.filePaths?.[0]) return { ok: false, reason: 'cancelled' }\n let data\n try {\n const stat = fs.statSync(res.filePaths[0])\n if (stat.size > 300 * 1024 * 1024) return { ok: false, reason: 'File larger than 300MB' }\n data = JSON.parse(fs.readFileSync(res.filePaths[0], 'utf8'))\n } catch (err) {\n return { ok: false, reason: 'Could not parse: ' + err.message }\n }\n return require('./import-chatgpt').convertChatGPTExport(data)\n})\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => { try { mainWindow?.show(); mainWindow?.focus() } catch {} })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "b81eef42e43d67a861382a1b88ffd8030b7cbf01": {"t": 1785766035.0767002, "n": 87368, "h": "be293075ce688f47", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences, Notification } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n // v0.8 retention: a routine that runs silently into a toast\n // might as well not exist. Surface it as a real macOS\n // notification when the window isn't focused.\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n const n = new Notification({ title: `⏰ ${routine.name}`, body: preview.slice(0, 140) })\n n.on('click', () => { try { mainWindow?.show(); mainWindow?.focus() } catch {} })\n n.show()\n }\n } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n try {\n if (Notification.isSupported() && !mainWindow?.isFocused()) {\n new Notification({ title: `⏰ ${routine.name} failed`, body: String(err?.message || err).slice(0, 140) }).show()\n }\n } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "3875d78a0d7ecf1dbd9c4184643003a56dce3cf6": {"t": 1785766037.4250312, "n": 86534, "h": "1a77666174889db8", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8 market-fit: paste-any-key provider detection. Pattern fast-paths\n// for unambiguous formats; the ambiguous sk-… family gets resolved by\n// probing each candidate's cheap GET /models endpoint in parallel with the\n// pasted key — first authenticated 200 wins.\nconst KEY_PATTERNS = [\n [/^sk-ant-/, 'anthropic'],\n [/^AIza/, 'google'],\n [/^xai-/, 'xai'],\n [/^eyJ[A-Za-z0-9_-]+\\./, 'minimax'], // JWT\n [/^[0-9a-f]{16,}\\.[A-Za-z0-9]+$/, 'zhipu'], // id.secret\n]\nconst PROBE_ORDER = ['openai', 'deepseek', 'moonshot', 'kimi', 'dashscope', 'hunyuan', 'zhipu']\nfunction probeProviderKey(provider, key) {\n const https = require('https')\n const base = (getBaseURL(provider) || 'https://api.openai.com/v1').replace(/\\/$/, '')\n return new Promise((resolve) => {\n let u\n try { u = new URL(base + '/models') } catch { return resolve(null) }\n const req = https.request(u, { method: 'GET', headers: { Authorization: `Bearer ${key}` }, timeout: 6000 }, (res) => {\n res.resume()\n resolve(res.statusCode === 200 ? provider : null)\n })\n req.on('error', () => resolve(null))\n req.on('timeout', () => { try { req.destroy() } catch {}; resolve(null) })\n req.end()\n })\n}\nipcMain.handle('login-detect-key', async (_e, rawKey) => {\n const key = String(rawKey || '').trim()\n if (!key) return { provider: null }\n for (const [re, prov] of KEY_PATTERNS) {\n if (re.test(key)) return { provider: prov, method: 'pattern' }\n }\n const hits = (await Promise.all(PROBE_ORDER.map((p) => probeProviderKey(p, key)))).filter(Boolean)\n if (hits.length) return { provider: hits[0], method: 'probe', also: hits.slice(1) }\n return { provider: null }\n})\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "8275d3a76f9213d9f35306ca064ba9031692b157": {"t": 1785766039.6464393, "n": 84853, "h": "14cd9ae4cfac9e21", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "8ffb6c66220864851e9504c2dc567e8f38c5271a": {"t": 1785766041.3050075, "n": 85034, "h": "e2cfa140a62fadc6", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n // Test harnesses (scripts/test-boot.mjs) point LABAIK_USERDATA at a temp\n // dir so a smoke-test instance can't fight the user's running app over\n // the LevelDB lock — and can't touch real localStorage.\n if (process.env.LABAIK_USERDATA) {\n app.setPath('userData', process.env.LABAIK_USERDATA)\n } else {\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { label: 'Open Future Console', click: () => createFutureWindow() },\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { label: 'Future Console', accelerator: 'CmdOrCtrl+Shift+F', click: () => createFutureWindow() },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "22b1b7dc4f62cff08daccc17855a89821c2a22f7": {"t": 1785766043.1607955, "n": 84698, "h": "ad0f3745b6c978b3", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\n// v0.8: Stop generation. Cancels every in-flight worker chat — the worker\n// aborts its provider stream(s) and each chat resolves with partial text.\nipcMain.handle('chat-cancel-all', () => {\n let n = 0\n for (const id of pendingRequests.keys()) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'chat-cancel', id }) + '\\n'); n++ } catch {}\n }\n return n\n})\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { label: 'Open Future Console', click: () => createFutureWindow() },\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { label: 'Future Console', accelerator: 'CmdOrCtrl+Shift+F', click: () => createFutureWindow() },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "f4c0ac785ce0ea8705e9c39aada073a68d74ce9e": {"t": 1785766045.2463036, "n": 84329, "h": "d99e7689b1f2d445", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n// v0.8 — one-click general-use starter skills (idempotent; never overwrites).\nipcMain.handle('folder-skills-install-starters', () => folderSkills.installStarters())\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { label: 'Open Future Console', click: () => createFutureWindow() },\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { label: 'Future Console', accelerator: 'CmdOrCtrl+Shift+F', click: () => createFutureWindow() },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "829b713fe2f36abb0943976523fda44ec3d41ec6": {"t": 1785766046.9822588, "n": 84163, "h": "d1899465eaf1cade", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n chatId: resp.chatId, // pendingRequests key — workerId is the ap_ bridge id\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.chatId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { label: 'Open Future Console', click: () => createFutureWindow() },\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { label: 'Future Console', accelerator: 'CmdOrCtrl+Shift+F', click: () => createFutureWindow() },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}, "1bd958e35fdc99327ffede8260f5e2a8983f0868": {"t": 1785766049.001202, "n": 84084, "h": "c3d7d3eafe8d769c", "c": "const { app, BrowserWindow, Menu, Tray, ipcMain, shell, dialog, globalShortcut, nativeImage, screen, systemPreferences } = require('electron')\nconst path = require('path')\nconst os = require('os')\nconst http = require('http')\nconst crypto = require('crypto')\n\n// EPIPE on stdout/stderr crashes the app when the parent shell exits or\n// a background-task wrapper reaps our FDs (seen in dev runs launched via\n// `npm start &`). Attach a no-op error handler so a closed pipe doesn't\n// bubble to Electron's \"Uncaught Exception\" dialog. Anyone who needs to\n// know about a write failure can wrap their own write in try/catch.\nfor (const stream of [process.stdout, process.stderr]) {\n stream.on('error', (err) => { if (err && err.code !== 'EPIPE') throw err })\n}\n\n// Identify as \"Alaude\" EVERYWHERE macOS might label us — menu bar, About\n// panel, notification sender, dock badge, user-data-dir. Happens during\n// dev runs (`npm start`), where the Electron binary would otherwise call\n// itself \"Electron\" and the parent process might leak its own name (e.g.\n// \"Claude\" when launching via the Claude CLI) into permission dialogs.\n// The packaged DMG already has the right name baked into Info.plist, so\n// this is a belt-and-braces measure.\n// Pin the user-data directory FIRST (before setName, which would move it).\n// Existing users have sessions / localStorage / cookies at\n// ~/Library/Application Support/alaude-desktop — if we let Electron\n// derive the dir from our app name it'd silently jump to /Alaude and\n// orphan everything. If the packaged app didn't pre-create a userData\n// dir (first-run on a fresh install) we fall back to Electron's default.\ntry {\n const fsMod = require('fs')\n const legacyDir = path.join(app.getPath('appData'), 'alaude-desktop')\n const brandedDir = path.join(app.getPath('appData'), 'Alaude')\n // Prefer legacy if it exists (keep existing users' data). Use branded\n // only on a truly fresh install where neither dir has anything.\n if (fsMod.existsSync(legacyDir)) {\n app.setPath('userData', legacyDir)\n } else if (fsMod.existsSync(brandedDir)) {\n app.setPath('userData', brandedDir)\n }\n // Otherwise leave as-is; setName below will produce /Alaude on first run.\n} catch {}\n\napp.setName('Labaik')\ntry { app.setAboutPanelOptions({ applicationName: 'Labaik', credits: 'https://labaik.ai' }) } catch {}\nconst ollama = require('./ollama')\nconst modelCatalog = require('./model-catalog')\nconst ooda = require('./ooda')\nconst permissions = require('./permissions')\nconst routines = require('./routines')\nconst folderSkills = require('./folder-skills')\nconst mcp = require('./mcp')\nconst jsonStore = require('./json-store')\nconst paths = require('./paths')\nconst { detectProvider, getBaseURL, PROVIDER_KEY_IDS } = require('./provider-registry')\n\n// ── Permission mode persistence (v0.4.0) ──────────────────────────────────\n// Stored in ~/.labaik/permissions.json so it survives reinstalls. All four\n// modes (Observe/Careful/Flow/Autopilot) are live; Careful/Flow surface the\n// approval dialog via the worker→main→renderer gate (see handleApprovalRequest).\nconst PERMISSIONS_FILE = paths.resolveWithMigration(\n path.join(paths.BASE_DIR, 'permissions.json'),\n [path.join(paths.LEGACY_ALAUDE_DIR, 'permissions.json')]\n)\nlet permState = null // { version, defaultMode, workspaces: { [path]: { mode, allow, deny } } }\n\nfunction loadPermissions() {\n try {\n const fs = require('fs')\n if (!fs.existsSync(PERMISSIONS_FILE)) return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n const raw = fs.readFileSync(PERMISSIONS_FILE, 'utf8')\n const parsed = JSON.parse(raw)\n if (!parsed || typeof parsed !== 'object') throw new Error('malformed')\n if (!parsed.workspaces || typeof parsed.workspaces !== 'object') parsed.workspaces = {}\n if (!permissions.MODES.includes(parsed.defaultMode)) parsed.defaultMode = 'autopilot'\n return parsed\n } catch (err) {\n console.warn('[permissions] could not load, using defaults:', err.message)\n return { version: 1, defaultMode: 'autopilot', workspaces: {} }\n }\n}\nfunction savePermissions(state) {\n try {\n const fs = require('fs')\n fs.mkdirSync(path.dirname(PERMISSIONS_FILE), { recursive: true })\n fs.writeFileSync(PERMISSIONS_FILE, JSON.stringify(state, null, 2), 'utf8')\n } catch (err) {\n console.error('[permissions] save failed:', err.message)\n }\n}\nfunction getCurrentMode(workspacePath) {\n if (!permState) permState = loadPermissions()\n if (workspacePath && permState.workspaces?.[workspacePath]?.mode) {\n const m = permState.workspaces[workspacePath].mode\n if (permissions.MODES.includes(m)) return m\n }\n return permState.defaultMode\n}\nfunction setCurrentMode(workspacePath, mode) {\n if (!permissions.MODES.includes(mode)) return false\n if (!permState) permState = loadPermissions()\n if (workspacePath) {\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n permState.workspaces[workspacePath].mode = mode\n } else {\n permState.defaultMode = mode\n }\n savePermissions(permState)\n return true\n}\n\n// v0.7.61: was a third duplicate of detectProvider (for UX telemetry).\n// Now just an alias to the shared registry function — the registry is\n// the same logic with more providers and no divergence risk.\nconst detectProviderForUx = detectProvider\n\nfunction classifyError(err) {\n const msg = String(err?.message || err || '').toLowerCase()\n if (msg.includes('connection') || msg.includes('enotfound') || msg.includes('econn')) return 'connection'\n if (msg.includes('timed out') || msg.includes('timeout')) return 'timeout'\n if (msg.includes('api key') || msg.includes('unauthorized') || msg.includes('401')) return 'auth'\n if (msg.includes('rate limit') || msg.includes('429')) return 'rate_limit'\n if (msg.includes('model')) return 'model'\n return 'other'\n}\n\n// ── State ────────────────────────────────────────────────────────────────────\n\nlet mainWindow = null\nlet quickWindow = null\nlet futureWindow = null\nlet tray = null\n\n// ── Window ───────────────────────────────────────────────────────────────────\n\nfunction createWindow() {\n mainWindow = new BrowserWindow({\n width: 1100,\n height: 750,\n minWidth: 700,\n minHeight: 500,\n backgroundColor: '#f5f5f5',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n // v0.7.48: pass the app version + homepage via argv because sandboxed\n // preloads can't require('../package.json'). Preload parses these out\n // of process.argv and exposes them on window.alaude.\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'))\n\n // DEV helper: ALAUDE_DEVTOOLS=1 auto-opens devtools so artifact-iframe\n // script errors surface immediately. Ship as detached pane.\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n mainWindow.webContents.once('did-finish-load', () => {\n mainWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n // Allow DevTools in packaged + dev builds so renderer-side bugs (chart\n // errors, artifact script issues) are debuggable. ⌘⌥I on macOS, Ctrl+Shift+I\n // elsewhere. Also relay any uncaught renderer errors to main's stderr so\n // the log captures them.\n mainWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) mainWindow.webContents.toggleDevTools()\n })\n mainWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n // EPIPE-safe: when the parent shell exits (or a background-task\n // wrapper reaps our stderr FD) the next sync write throws EPIPE\n // which Electron surfaces as an \"Uncaught Exception\" dialog. We\n // can't log the failure-to-log, so just drop it.\n try { process.stderr.write(`[renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n // Intercept every window.open (including target=\"_blank\" link clicks inside\n // AI responses). Electron's default is to create a new BrowserWindow and\n // load the URL inside it — which means a dead localhost link produces\n // \"Failed to load URL\" spam, and even good URLs open as a stripped-down\n // embedded window instead of the user's real browser.\n //\n // Route all external navigations to the OS default browser instead.\n mainWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n // Same safety net for in-page navigation (e.g. user drags a URL into the\n // window) — don't let the main renderer ever navigate away from our HTML.\n mainWindow.webContents.on('will-navigate', (event, url) => {\n const cur = mainWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n mainWindow.on('closed', () => { mainWindow = null })\n}\n\nfunction createFutureWindow() {\n if (futureWindow && !futureWindow.isDestroyed()) {\n futureWindow.show()\n futureWindow.focus()\n return futureWindow\n }\n\n futureWindow = new BrowserWindow({\n width: 1280,\n height: 820,\n minWidth: 900,\n minHeight: 620,\n backgroundColor: '#050807',\n title: 'Labaik Future Console',\n titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',\n trafficLightPosition: { x: 15, y: 15 },\n webPreferences: {\n preload: path.join(__dirname, 'preload.js'),\n nodeIntegration: false,\n contextIsolation: true,\n additionalArguments: [\n `--alaude-version=${app.getVersion()}`,\n `--alaude-homepage=${(() => { try { return require(path.join(__dirname, '..', 'package.json')).homepage || '' } catch { return '' } })()}`,\n ],\n },\n icon: path.join(__dirname, '..', 'build', 'icons', 'icon.png'),\n })\n\n futureWindow.loadFile(path.join(__dirname, '..', 'renderer', 'future.html'))\n\n if (process.env.ALAUDE_DEVTOOLS === '1') {\n futureWindow.webContents.once('did-finish-load', () => {\n futureWindow.webContents.openDevTools({ mode: 'detach' })\n })\n }\n\n futureWindow.webContents.on('before-input-event', (_e, input) => {\n const mac = process.platform === 'darwin'\n const opensTools = input.type === 'keyDown' && input.key === 'i' &&\n ((mac && input.meta && input.alt) || (!mac && input.control && input.shift))\n if (opensTools) futureWindow.webContents.toggleDevTools()\n })\n\n futureWindow.webContents.on('console-message', (_e, level, message, line, source) => {\n const lvl = ['log','warn','error'][level] || 'log'\n if (lvl === 'error' || lvl === 'warn') {\n try { process.stderr.write(`[future-renderer ${lvl}] ${message} (${source}:${line})\\n`) } catch {}\n }\n })\n\n futureWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('file://') || url.startsWith('mailto:'))) {\n shell.openExternal(url)\n }\n return { action: 'deny' }\n })\n\n futureWindow.webContents.on('will-navigate', (event, url) => {\n const cur = futureWindow.webContents.getURL()\n if (url !== cur) {\n event.preventDefault()\n if (url.startsWith('http://') || url.startsWith('https://')) {\n shell.openExternal(url)\n }\n }\n })\n\n futureWindow.on('closed', () => { futureWindow = null })\n return futureWindow\n}\n\n// ── AI Chat backend ─────────────────────────────────────────────────────────\n\n/**\n * Load the credential manager from the alaude source\n */\nconst SRC_DIR = path.resolve(__dirname, '..', '..', 'claude_code_src')\n\n// Returns { value, isOauth } | null. Prefers (in order): OAuth token from\n// the credentials file, API key from the credentials file, env var.\n// Env vars are ALWAYS treated as API keys — set your shell if you want\n// that path.\nfunction getCredential(provider) {\n const fs = require('fs')\n const envMap = {\n anthropic: 'ANTHROPIC_API_KEY',\n openai: 'OPENAI_API_KEY',\n google: 'GEMINI_API_KEY',\n xai: 'XAI_API_KEY',\n moonshot: 'MOONSHOT_API_KEY',\n dashscope: 'DASHSCOPE_API_KEY',\n zhipu: 'ZHIPU_API_KEY',\n }\n // v0.7.64: credentials now live at ~/.labaik/credentials.json. We\n // still READ from the legacy ~/.claude/.credentials.json (and the\n // `claude-local-src/` dev path) so existing users don't lose access —\n // first-save will copy them over to the new location.\n const credPaths = [\n paths.CREDENTIALS_FILE,\n path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json'),\n ...(paths.USING_CUSTOM_HOME ? [] : [path.join(os.homedir(), 'claude-local-src', '.credentials.json')]),\n ]\n for (const credPath of credPaths) {\n try {\n if (!fs.existsSync(credPath)) continue\n const data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n const oauth = data?.providerOauthTokens?.[provider]\n if (oauth) return { value: oauth, isOauth: true }\n const apiKey = data?.providerApiKeys?.[provider]\n if (apiKey) return { value: apiKey, isOauth: false }\n } catch {}\n }\n const envKey = process.env[envMap[provider]]\n if (envKey) return { value: envKey, isOauth: false }\n return null\n}\n\n// Backwards-compat: some older callers just want the string value. Returns\n// null if no credential exists. Callers that care about OAuth vs API\n// should use getCredential() directly.\nfunction getApiKey(provider) {\n const c = getCredential(provider)\n return c ? c.value : null\n}\n\n// v0.7.61: `detectProvider` and `getBaseURL` moved to\n// ./provider-registry.js so main + worker route identically. Imported\n// at the top of this file.\n\n// ── OAuth PKCE flow ─────────────────────────────────────────────────────────\n\nconst OAUTH_CONFIG = {\n clientId: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',\n authorizeUrl: 'https://claude.com/cai/oauth/authorize',\n tokenUrl: 'https://platform.claude.com/v1/oauth/token',\n scopes: 'user:profile user:inference',\n}\n\nfunction base64url(buffer) {\n return buffer.toString('base64').replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction generatePKCE() {\n const verifier = base64url(crypto.randomBytes(32))\n const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())\n return { verifier, challenge }\n}\n\nipcMain.handle('oauth-login', async (_, provider) => {\n if (provider === 'anthropic') {\n return await oauthAnthropic()\n }\n throw new Error(`OAuth not supported for ${provider} yet. Use API key instead.`)\n})\n\nfunction oauthAnthropic() {\n return new Promise((resolve, reject) => {\n const { verifier, challenge } = generatePKCE()\n const state = base64url(crypto.randomBytes(16))\n const port = 18923 + Math.floor(Math.random() * 100)\n\n // Start local callback server\n const server = http.createServer(async (req, res) => {\n if (!req.url?.startsWith('/callback')) {\n res.writeHead(404)\n res.end()\n return\n }\n\n const url = new URL(req.url, `http://localhost:${port}`)\n const code = url.searchParams.get('code')\n const returnedState = url.searchParams.get('state')\n\n if (returnedState !== state || !code) {\n res.writeHead(400, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Login failed</h2><p>Invalid state or missing code. Please try again.</p></body></html>')\n server.close()\n reject(new Error('OAuth state mismatch'))\n return\n }\n\n // Exchange code for tokens\n try {\n const tokenRes = await fetch(OAUTH_CONFIG.tokenUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n grant_type: 'authorization_code',\n client_id: OAUTH_CONFIG.clientId,\n code,\n redirect_uri: `http://localhost:${port}/callback`,\n code_verifier: verifier,\n }),\n })\n\n if (!tokenRes.ok) {\n const errText = await tokenRes.text()\n throw new Error(`Token exchange failed: ${tokenRes.status} ${errText}`)\n }\n\n const tokens = await tokenRes.json()\n const accessToken = tokens.access_token\n\n // Save the OAuth access token under the oauth slot (not the api-key\n // slot) so chatAnthropic can pass it as a Bearer token instead of\n // x-api-key.\n saveCredential('anthropic', accessToken, 'oauth')\n\n res.writeHead(200, { 'Content-Type': 'text/html' })\n res.end('<html><body style=\"font-family:sans-serif;text-align:center;padding:60px;background:#f5f5f5\"><h2 style=\"color:#00a846\">Logged in!</h2><p>You can close this window and return to Labaik.</p><script>setTimeout(()=>window.close(),2000)</script></body></html>')\n server.close()\n\n // Notify the renderer\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('login-success', 'anthropic')\n }\n\n resolve({ provider: 'anthropic', success: true })\n } catch (err) {\n res.writeHead(500, { 'Content-Type': 'text/html' })\n res.end(`<html><body style=\"font-family:sans-serif;text-align:center;padding:60px\"><h2>Error</h2><p>${err.message}</p></body></html>`)\n server.close()\n reject(err)\n }\n })\n\n // Fail fast if the random port is already in use (was hanging until the\n // 5-min timeout because no 'error' handler existed on the server).\n server.on('error', (err) => { try { server.close() } catch {}; reject(err) })\n server.listen(port, '127.0.0.1', () => {\n // Build the OAuth URL\n const authUrl = new URL(OAUTH_CONFIG.authorizeUrl)\n authUrl.searchParams.set('client_id', OAUTH_CONFIG.clientId)\n authUrl.searchParams.set('response_type', 'code')\n authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`)\n authUrl.searchParams.set('scope', OAUTH_CONFIG.scopes)\n authUrl.searchParams.set('code_challenge', challenge)\n authUrl.searchParams.set('code_challenge_method', 'S256')\n authUrl.searchParams.set('state', state)\n authUrl.searchParams.set('code', 'true')\n\n // Open browser\n shell.openExternal(authUrl.toString())\n })\n\n // Timeout after 5 minutes\n setTimeout(() => {\n server.close()\n reject(new Error('Login timed out'))\n }, 300000)\n })\n}\n\nfunction saveCredential(provider, key, kind /* 'api' | 'oauth' */) {\n const fs = require('fs')\n // v0.7.64: writes go to ~/.labaik/credentials.json. Reads still fall\n // back to ~/.claude/.credentials.json until the user saves a key, at\n // which point the new file becomes authoritative.\n const credPath = paths.CREDENTIALS_FILE\n const legacyCredPath = path.join(paths.LEGACY_CLAUDE_DIR, '.credentials.json')\n\n let data = {}\n try {\n if (fs.existsSync(credPath)) {\n data = JSON.parse(fs.readFileSync(credPath, 'utf8'))\n } else if (fs.existsSync(legacyCredPath)) {\n // First write after upgrade: seed from the legacy file so other\n // providers' keys don't get wiped when saving one.\n data = JSON.parse(fs.readFileSync(legacyCredPath, 'utf8'))\n }\n } catch {}\n\n if (kind === 'oauth') {\n if (!data.providerOauthTokens) data.providerOauthTokens = {}\n data.providerOauthTokens[provider] = key\n // Clear any stale API key in the same slot — OAuth wins.\n if (data.providerApiKeys && data.providerApiKeys[provider]) {\n delete data.providerApiKeys[provider]\n }\n } else {\n if (!data.providerApiKeys) data.providerApiKeys = {}\n data.providerApiKeys[provider] = key\n // And clear any stale OAuth token so API-key-set replaces it.\n if (data.providerOauthTokens && data.providerOauthTokens[provider]) {\n delete data.providerOauthTokens[provider]\n }\n }\n\n paths.ensureBaseDir()\n fs.writeFileSync(credPath, JSON.stringify(data, null, 2), { mode: 0o600 })\n}\n\n// ── IPC: Chat ────────────────────────────────────────────────────────────────\n\n// ── API Worker (runs in plain Node.js to avoid Electron network issues) ──────\n\nconst { spawn: spawnChild } = require('child_process')\n\n// ── v0.7.40 — dev server lifecycle tracking ──────────────────────────\n// Servers started via the worker's start_dev_server tool live as detached\n// child processes. Without tracking them, they accumulate across sessions:\n// closing Labaik doesn't stop them, so the next test that picks the same\n// port silently collides with a zombie server (the recurring \"localhost\n// serves old files\" bug). We store PIDs here and kill them on:\n// - window close (before quit)\n// - new session (renderer sends 'kill-tracked-servers')\n// Tracking is lossy across Labaik restarts (servers from a previous run\n// are orphaned) — mostly that's fine since they die with the shell; the\n// stuck ones are the ones we started in THIS process.\nlet trackedServers = []\nfunction killAllTrackedServers(reason = 'cleanup') {\n const killed = []\n for (const s of trackedServers) {\n try {\n // Detached children started by `spawn(..., {detached: true})` run as\n // their own process group; -pid kills the whole group.\n try { process.kill(-s.pid, 'SIGTERM') } catch { process.kill(s.pid, 'SIGTERM') }\n killed.push(s.pid)\n } catch (err) {\n // Already dead / permission denied — ignore, continue to next.\n }\n }\n if (killed.length) console.log(`[server-cleanup] killed ${killed.length} tracked server(s) (${reason}):`, killed)\n trackedServers = []\n return killed\n}\n// Last-chance cleanup when Labaik itself is quitting.\napp?.on?.('before-quit', () => killAllTrackedServers('app-quit'))\nlet apiWorker = null\nlet requestId = 0\nconst pendingRequests = new Map()\n\nfunction getWorker() {\n if (apiWorker && !apiWorker.killed) return apiWorker\n\n // Binary selection:\n // Packaged app → always use Electron-as-Node (process.execPath +\n // ELECTRON_RUN_AS_NODE=1). Guaranteed present inside the .app bundle.\n // System `node` doesn't exist on every Mac, and even when it does it\n // can't read files packaged inside app.asar — which is where main.js\n // lives at runtime.\n // Dev mode → prefer a real system `node` (slightly snappier startup,\n // and the original reason we introduced the split). Fall back to\n // Electron-as-Node if no system node is found.\n const nodeBin = (() => {\n if (app.isPackaged) return process.execPath\n const fsMod = require('fs')\n const candidates = [\n '/usr/local/bin/node',\n '/opt/homebrew/bin/node',\n path.join(os.homedir(), '.nvm/versions/node', 'current', 'bin', 'node'),\n ]\n for (const c of candidates) { if (fsMod.existsSync(c)) return c }\n return process.execPath\n })()\n const workerEnv = { ...process.env }\n if (nodeBin === process.execPath) workerEnv.ELECTRON_RUN_AS_NODE = '1'\n\n console.log('[worker] spawning with binary:', nodeBin, '(packaged:', app.isPackaged, ')')\n apiWorker = spawnChild(nodeBin, [path.join(__dirname, 'api-worker.js')], {\n stdio: ['pipe', 'pipe', 'inherit'],\n env: workerEnv,\n })\n\n // Surface worker-side crashes so \"Worker crashed\" is actionable, not silent\n apiWorker.on('error', (err) => console.error('[worker] spawn error:', err))\n apiWorker.stderr?.on('data', (chunk) => process.stderr.write(`[worker stderr] ${chunk}`))\n\n let buf = ''\n apiWorker.stdout.setEncoding('utf8')\n apiWorker.stdout.on('data', (chunk) => {\n buf += chunk\n let idx\n while ((idx = buf.indexOf('\\n')) !== -1) {\n const line = buf.slice(0, idx)\n buf = buf.slice(idx + 1)\n if (!line.trim()) continue\n try {\n const resp = JSON.parse(line)\n // Live in-flight events (tool calls, thinking steps). Forward to\n // renderer but DON'T resolve the pending promise — the final\n // {id, result} still follows.\n if (resp.activity) {\n // Route activity to the window that actually sent this request.\n // Broadcasting to both windows leaked Quick-window tool calls onto\n // the main chat (and vice versa) when both were in use.\n const pendingForActivity = pendingRequests.get(resp.id)\n const sender = pendingForActivity?.sender\n try {\n if (sender && !sender.isDestroyed()) sender.send('tool-activity', resp.activity)\n else mainWindow?.webContents?.send('tool-activity', resp.activity)\n } catch {}\n // Each activity tick resets the \"no progress\" idle timer. Long\n // website-builds etc. that stream for minutes now stay alive.\n try { pendingForActivity?.onActivity?.() } catch {}\n continue\n }\n // v0.7.40: worker spawned a dev server via start_dev_server. Track\n // its PID so we can kill it on window close / new session — avoids\n // the \"stale Python server holding port 8000\" bug where every new\n // test started a new server on top of a zombie one.\n if (resp.type === 'server_started' && typeof resp.pid === 'number') {\n trackedServers.push({\n pid: resp.pid,\n port: resp.port,\n command: resp.command,\n workspacePath: resp.workspacePath,\n startedAt: Date.now(),\n })\n // Cap memory: keep the last 20 (oldest dropped, but still killed\n // as part of killAllTrackedServers on app quit).\n if (trackedServers.length > 20) trackedServers = trackedServers.slice(-20)\n continue\n }\n // v0.5.6: MCP tool bridge. Worker asks main for the current MCP\n // tool schemas (so it can merge them into the request) or to execute\n // an MCP tool call. Same shape as the browser-tool bridge.\n if (resp.type === 'mcp-list') {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-list-response', id: resp.id, tools: mcp.getToolSchemas() }) + '\\n') } catch {}\n continue\n }\n if (resp.type === 'mcp-call') {\n ;(async () => {\n let result\n try { result = await mcp.callTool(resp.name, resp.args || {}) }\n catch (err) { result = { error: String(err?.message || err) } }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'mcp-call-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.4.1: approval request. The worker wants to run a side-effecting\n // tool; main resolves the permission gate (it owns the rules) and,\n // if the verdict is 'prompt', asks the user via the renderer dialog.\n if (resp.type === 'approval') {\n const pendingForApproval = pendingRequests.get(resp.chatId)\n handleApprovalRequest(resp, pendingForApproval)\n continue\n }\n // v0.5.5: Browser Agent tool request from the worker. We run the\n // actual Electron BrowserWindow API here in main (the worker can't\n // touch BrowserWindow) and write the result back to its stdin.\n if (resp.type === 'browser-tool') {\n const bA = require('./browser-agent')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'browser_navigate') result = await bA.navigate(a.url)\n else if (resp.name === 'browser_get_text') result = await bA.getText(a.selector)\n else if (resp.name === 'browser_click') result = await bA.click(a.selector)\n else if (resp.name === 'browser_fill') result = await bA.fill(a.selector, a.text)\n else if (resp.name === 'browser_screenshot') result = await bA.screenshot()\n else result = { error: `unknown browser tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'browser-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n // v0.5.10: Screen control tool request (full desktop, not just browser).\n if (resp.type === 'screen-tool') {\n const sC = require('./screen-control')\n ;(async () => {\n let result\n try {\n const a = resp.args || {}\n if (resp.name === 'screen_click') result = await sC.click(a)\n else if (resp.name === 'screen_type') result = await sC.type(a)\n else if (resp.name === 'screen_key') result = await sC.key(a)\n else if (resp.name === 'screen_move_mouse') result = await sC.moveMouse(a)\n else result = { error: `unknown screen tool: ${resp.name}` }\n } catch (err) {\n result = { error: String(err?.message || err) }\n }\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'screen-tool-response', id: resp.id, result }) + '\\n') } catch {}\n })()\n continue\n }\n const pending = pendingRequests.get(resp.id)\n if (pending) {\n pendingRequests.delete(resp.id)\n if (resp.error) pending.reject(new Error(resp.error))\n else pending.resolve(resp.result)\n }\n } catch {}\n }\n })\n\n apiWorker.on('exit', () => {\n apiWorker = null\n for (const [id, p] of pendingRequests) {\n p.reject(new Error('Worker crashed'))\n pendingRequests.delete(id)\n }\n })\n\n return apiWorker\n}\n\nipcMain.handle('chat', async (event, messagesRaw, model, workspacePath, spaceId, uxMeta) => {\n const id = ++requestId\n const worker = getWorker()\n const senderWebContents = event?.sender\n\n // Compose system prompt from active space\n let spacePrompt = ''\n if (spaceId) {\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) {\n spacePrompt = builtIn.systemPromptAddition || ''\n } else {\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n spacePrompt = custom?.systemPromptAddition || ''\n }\n }\n\n // ── OODA: log chat_send ──\n const messageId = uxMeta?.messageId || `m_${id}_${Date.now()}`\n const sessionId = uxMeta?.sessionId || 'unknown'\n const lastUser = messagesRaw.slice().reverse().find(m => m.role === 'user')\n const promptLen = lastUser ? String(lastUser.content || '').length : 0\n const provider = detectProviderForUx(model)\n const sendTs = Date.now()\n ooda.logEvent({\n kind: 'chat_send',\n sessionId,\n messageId,\n space: spaceId || 'general',\n provider,\n model: model || '',\n entry: uxMeta?.entry || 'freeform',\n action: uxMeta?.action || null,\n hasWorkspace: !!workspacePath,\n hasAttachments: !!uxMeta?.hasAttachments,\n promptHash: uxMeta?.promptHash || null,\n promptLen,\n })\n\n const finalize = (success, errorMsg, responseLen) => {\n ooda.logEvent({\n kind: success ? 'chat_complete' : 'chat_error',\n sessionId,\n messageId,\n success,\n latencyMs: Date.now() - sendTs,\n errorType: success ? null : classifyError(errorMsg),\n errorMsg: success ? null : String(errorMsg || '').slice(0, 200),\n responseLen: responseLen || 0,\n })\n // chat_complete-kinded event must exist for outcome builder — emit a synthetic\n // completion marker on error too so the outcome is captured.\n if (!success) {\n ooda.logEvent({\n kind: 'chat_complete',\n sessionId,\n messageId,\n success: false,\n latencyMs: Date.now() - sendTs,\n errorType: classifyError(errorMsg),\n })\n }\n // Fire-and-forget batch check\n setImmediate(() => {\n const res = ooda.maybeRunBatch()\n if (res?.ran) console.log(`[ooda] batch #${res.batchId} ran — ${res.diagnosis?.problem}`)\n })\n }\n\n return new Promise((resolve, reject) => {\n // Activity-based idle timeout. Previous design was a hard wall-clock\n // cap (2 min cloud / 10 min local) that killed legit long workflows\n // like 'build me a website' — models that tool-call + stream for 3+\n // min would get cut off even though every turn was making progress.\n // Now: each activity event (token chunk, tool_start, tool_end,\n // file_edit, thinking) resets the timer. Only a genuinely silent\n // provider/worker bails out.\n const isLocalModel = provider === 'ollama'\n // v0.7.69: reasoning-model carve-out. Models that \"think\" before\n // emitting visible tokens (DeepSeek V4 / Reasoner, OpenAI o1/o3,\n // any *-thinking variant, GLM-4.5-thinking, Qwen3-thinking) can\n // legitimately be silent for 2-5 minutes mid-analysis. The default\n // cloud cap of 90s killed those requests with the misleading\n // \"Provider is silent\" error. Real-world repro: DeepSeek-v4-pro\n // on a multi-file code review crashed at the 90s mark.\n const isReasoningModel = /reasoner|thinking|^o[13]|^gpt-5.*think|deepseek-v4|deepseek-reasoner/i.test(model || '')\n // Idle (no-activity) cap. Reasoning + local both get 5 min; other\n // cloud models get 3 min (was 90s — still strong enough to surface\n // a genuinely dead socket without amputating legit long thinking).\n const IDLE_MS = (isLocalModel || isReasoningModel) ? 5 * 60 * 1000 : 3 * 60 * 1000\n // Absolute ceiling so a model in an infinite tool-loop doesn't burn\n // credits forever even if it keeps emitting activity.\n const ABS_MS = isLocalModel ? 30 * 60 * 1000 : 15 * 60 * 1000\n let idleTimer = null\n const absTimer = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n const err = new Error(`Request exceeded hard cap (${Math.round(ABS_MS / 60000)} min). The model was still producing but hit the overall safety ceiling — split the task into smaller turns.`)\n finalize(false, err); reject(err)\n }\n }, ABS_MS)\n const armIdle = () => {\n if (idleTimer) clearTimeout(idleTimer)\n idleTimer = setTimeout(() => {\n // Don't kill a request that's parked on a human approval dialog —\n // re-arm and keep waiting (the 10-min worker-side cap + ABS_MS\n // ceiling still bound the wait).\n if (pendingRequests.get(id)?.awaitingApproval) { armIdle(); return }\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n clearTimeout(absTimer)\n const secs = Math.round(IDLE_MS / 1000)\n const err = new Error(`No activity for ${secs}s. ${isLocalModel\n ? 'Local model may be stuck — check Ollama is running and try a shorter prompt.'\n : 'Provider is silent — could be rate-limit, a dead socket, or network trouble.'}`)\n finalize(false, err); reject(err)\n }\n }, IDLE_MS)\n }\n pendingRequests.set(id, {\n sender: senderWebContents,\n resolve: (result) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(true, null, String(result || '').length); resolve(result)\n },\n reject: (err) => {\n clearTimeout(absTimer); if (idleTimer) clearTimeout(idleTimer)\n finalize(false, err); reject(err)\n },\n // Called from the activity forwarder whenever the worker emits a\n // token / tool_start / tool_end / file_edit / thinking event.\n onActivity: armIdle,\n })\n // Pass the current permission mode for this workspace so the worker can\n // gate tool execution before the OS-level tool call actually runs.\n const mode = getCurrentMode(workspacePath)\n // v0.7.67 — plan mode propagates from the renderer through uxMeta. The\n // worker uses it to swap in the plan-only system prompt and skip tool\n // offerings so the model can't sneak past the \"no execution\" rule.\n const planMode = !!uxMeta?.planMode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model, workspacePath, spacePrompt, mode, planMode }) + '\\n'\n console.log('[chat] sending to worker, id:', id, 'space:', spaceId || 'general')\n worker.stdin.write(req, 'utf8')\n armIdle()\n })\n})\n\n// ── OODA IPC ─────────────────────────────────────────────────────────────────\nipcMain.handle('ux-event', (_e, event) => {\n try { ooda.logEvent(event); return true } catch { return false }\n})\nipcMain.handle('ux-insights', () => ooda.getLatestSummary())\nipcMain.handle('ux-run-batch', () => ooda.maybeRunBatch({ force: true }))\n\nasync function chatAnthropic(messagesRaw, model, workspacePath) {\n const AnthropicModule = require('@anthropic-ai/sdk')\n const Anthropic = AnthropicModule.default || AnthropicModule\n const apiKey = getApiKey('anthropic')\n if (!apiKey) throw new Error('No Anthropic API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new Anthropic({ apiKey, timeout: 60000 })\n\n const systemPrompt = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user. When writing code, always use write_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant.'\n\n const anthropicTools = workspacePath ? TOOLS.map(t => ({\n name: t.function.name,\n description: t.function.description,\n input_schema: t.function.parameters,\n })) : undefined\n\n const messages = messagesRaw.map(m => ({ role: m.role, content: m.content }))\n\n let fullResponse = ''\n let toolLog = ''\n\n for (let i = 0; i < 10; i++) {\n const response = await client.messages.create({\n model: model || 'claude-sonnet-4-5',\n max_tokens: 4096,\n system: systemPrompt,\n messages,\n ...(anthropicTools ? { tools: anthropicTools } : {}),\n })\n\n // Collect text\n for (const block of response.content) {\n if (block.type === 'text') fullResponse += block.text\n }\n\n // Handle tool use\n const toolUseBlocks = response.content.filter(b => b.type === 'tool_use')\n if (toolUseBlocks.length > 0) {\n messages.push({ role: 'assistant', content: response.content })\n\n const toolResults = []\n for (const tu of toolUseBlocks) {\n console.log(`[tool] ${tu.name}(${JSON.stringify(tu.input).slice(0, 200)})`)\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tu.name, tu.input)\n }\n\n const result = executeToolCall(tu.name, tu.input, workspacePath)\n\n if (tu.name === 'write_file') toolLog += `\\n📝 Wrote \\`${tu.input.path}\\``\n else if (tu.name === 'read_file') toolLog += `\\n📖 Read \\`${tu.input.path}\\``\n else if (tu.name === 'list_directory') toolLog += `\\n📁 Listed \\`${tu.input.path || '.'}\\``\n else if (tu.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${tu.input.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) })\n }\n\n messages.push({ role: 'user', content: toolResults })\n continue\n }\n\n break\n }\n\n return (fullResponse + toolLog) || '(Done)'\n}\n\nasync function chatOpenAI(messagesRaw, model, provider, workspacePath) {\n const OpenAIModule = require('openai')\n const OpenAI = OpenAIModule.default || OpenAIModule\n const apiKey = getApiKey(provider)\n if (!apiKey) throw new Error(`No ${provider} API key. Click \"API Keys\" in the top right to add one.`)\n\n const baseURL = getBaseURL(provider)\n const client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), timeout: 60000 })\n\n const systemMsg = workspacePath\n ? `You are Labaik, an AI coding assistant. You have access to the user's workspace at: ${workspacePath}\\n\\nYou can read files, write files, list directories, and run shell commands. Use the provided tools to help the user with coding tasks. When writing code, always use write_file to save it. When the user asks to see files, use read_file. Always explain what you're doing.`\n : 'You are Labaik, an AI coding assistant. Help the user with coding questions and tasks.'\n\n const messages = [\n { role: 'system', content: systemMsg },\n ...messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'assistant' : 'user',\n content: m.content,\n })),\n ]\n\n const useTools = workspacePath ? TOOLS : undefined\n let fullResponse = ''\n let toolLog = ''\n\n // Tool-calling loop (max 10 rounds)\n for (let i = 0; i < 10; i++) {\n const response = await client.chat.completions.create({\n model,\n messages,\n max_completion_tokens: 4096,\n ...(useTools ? { tools: useTools, tool_choice: 'auto' } : {}),\n })\n\n const choice = response.choices?.[0]\n const msg = choice?.message\n if (!msg) break\n\n // Add assistant response to conversation\n messages.push(msg)\n\n // Collect text content\n if (msg.content) fullResponse += msg.content\n\n // Handle tool calls\n if (msg.tool_calls && msg.tool_calls.length > 0) {\n for (const tc of msg.tool_calls) {\n const args = JSON.parse(tc.function.arguments || '{}')\n console.log(`[tool] ${tc.function.name}(${JSON.stringify(args).slice(0, 200)})`)\n\n // Notify renderer about tool activity\n if (mainWindow && !mainWindow.isDestroyed()) {\n mainWindow.webContents.send('tool-activity', tc.function.name, args)\n }\n\n const result = executeToolCall(tc.function.name, args, workspacePath)\n const resultStr = JSON.stringify(result)\n\n // Build tool activity log for display\n if (tc.function.name === 'write_file') {\n toolLog += `\\n📝 Wrote \\`${args.path}\\``\n } else if (tc.function.name === 'read_file') {\n toolLog += `\\n📖 Read \\`${args.path}\\``\n } else if (tc.function.name === 'list_directory') {\n toolLog += `\\n📁 Listed \\`${args.path || '.'}\\``\n } else if (tc.function.name === 'run_command') {\n toolLog += `\\n⚡ Ran \\`${args.command}\\``\n if (result.output) toolLog += `\\n\\`\\`\\`\\n${result.output.slice(0, 500)}\\n\\`\\`\\``\n }\n\n messages.push({\n role: 'tool',\n tool_call_id: tc.id,\n content: resultStr,\n })\n }\n continue // go another round so the model can respond with text\n }\n\n // No tool calls — we're done\n break\n }\n\n return (fullResponse + toolLog) || '(Done — no text response)'\n}\n\nasync function chatGemini(messagesRaw, model) {\n const { GoogleGenAI } = require('@google/genai')\n const apiKey = getApiKey('google')\n if (!apiKey) throw new Error('No Gemini API key. Click \"API Keys\" in the top right to add one.')\n\n const client = new GoogleGenAI({ apiKey })\n\n const chatMessages = messagesRaw.map(m => ({\n role: m.role === 'assistant' ? 'model' : 'user',\n parts: [{ text: m.content }],\n }))\n\n const response = await client.models.generateContent({\n model: model || 'gemini-2.0-flash',\n contents: chatMessages,\n })\n\n return response.text || ''\n}\n\n// ── IPC: Permission modes (v0.4.0) ──────────────────────────────────────────\n\nipcMain.handle('perm-get-mode', (_e, workspacePath) => {\n const mode = getCurrentMode(workspacePath)\n return { mode, meta: permissions.MODE_META[mode], allModes: permissions.MODES, allMeta: permissions.MODE_META }\n})\n\nipcMain.handle('perm-set-mode', (_e, workspacePath, mode) => {\n return setCurrentMode(workspacePath, mode)\n})\n\nipcMain.handle('perm-cycle-mode', (_e, workspacePath) => {\n const current = getCurrentMode(workspacePath)\n const next = permissions.nextMode(current)\n setCurrentMode(workspacePath, next)\n return { mode: next, meta: permissions.MODE_META[next] }\n})\n\nipcMain.handle('perm-get-state', () => {\n if (!permState) permState = loadPermissions()\n return permState\n})\n\n// ── Approval flow (v0.4.1) ──────────────────────────────────────────────────\n// The worker asks (via stdout) before running side-effecting tools. We resolve\n// the gate with the full rule set; an instant allow/deny replies immediately,\n// a 'prompt' verdict shows the renderer dialog and waits for the user.\nconst pendingApprovals = new Map() // approvalId -> { workerId, workspacePath, tool, args, floor }\n\nfunction summarizeToolArgs(tool, args) {\n const a = args || {}\n if (tool === 'run_command' || tool === 'start_dev_server') return String(a.command || '').slice(0, 200)\n if (tool === 'open_in_browser') return String(a.url || '').slice(0, 200)\n if (tool === 'write_file' || tool === 'read_file') return String(a.path || '').slice(0, 200)\n if (tool === 'list_directory') return String(a.path || '.').slice(0, 200)\n return ''\n}\n\nfunction replyApprovalToWorker(workerId, verdict, message) {\n try { apiWorker?.stdin.write(JSON.stringify({ type: 'approval-response', id: workerId, verdict, message }) + '\\n') } catch {}\n}\n\nfunction handleApprovalRequest(resp, pendingChat) {\n const workspacePath = resp.workspacePath || null\n const mode = getCurrentMode(workspacePath)\n if (!permState) permState = loadPermissions()\n let gate\n try {\n gate = permissions.resolveGate({\n tool: resp.tool,\n args: resp.args || {},\n mode,\n workspaceRoot: workspacePath,\n home: os.homedir(),\n rules: permState,\n })\n } catch (err) {\n console.warn('[approval] gate error, denying:', err.message)\n return replyApprovalToWorker(resp.id, 'deny', 'Permission check failed.')\n }\n\n if (gate.verdict === 'allow') return replyApprovalToWorker(resp.id, 'allow')\n if (gate.verdict === 'deny') return replyApprovalToWorker(resp.id, 'deny', gate.message || 'Blocked by permission policy.')\n\n // verdict === 'prompt' — surface the dialog and wait for the user.\n const target = (pendingChat?.sender && !pendingChat.sender.isDestroyed())\n ? pendingChat.sender\n : (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null)\n if (!target) return replyApprovalToWorker(resp.id, 'deny', 'No window available to confirm the action.')\n\n const approvalId = 'pa_' + (++requestId) + '_' + Date.now().toString(36)\n pendingApprovals.set(approvalId, {\n workerId: resp.id,\n workspacePath,\n tool: resp.tool,\n args: resp.args || {},\n floor: !!gate.floor,\n })\n // Keep the chat request alive while the user decides — otherwise the idle\n // timer would reject it mid-dialog. Cleared on response (see perm-respond).\n if (pendingChat) { pendingChat.awaitingApproval = true; pendingChat.onActivity?.() }\n\n try {\n target.send('permission-request', {\n approvalId,\n tool: resp.tool,\n summary: resp.summary || summarizeToolArgs(resp.tool, resp.args),\n reason: gate.reason || null,\n why: gate.detail?.why || null,\n floor: !!gate.floor, // protected paths / dangerous commands: no \"always\"\n mode,\n })\n } catch (err) {\n pendingApprovals.delete(approvalId)\n if (pendingChat) pendingChat.awaitingApproval = false\n replyApprovalToWorker(resp.id, 'deny', 'Could not show the approval dialog.')\n }\n}\n\nfunction addAllowRule(workspacePath, tool, args) {\n if (!workspacePath) return\n if (!permState) permState = loadPermissions()\n if (!permState.workspaces[workspacePath]) permState.workspaces[workspacePath] = {}\n const ws = permState.workspaces[workspacePath]\n if (!Array.isArray(ws.allow)) ws.allow = []\n const esc = (s) => String(s || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n let pattern\n if (tool === 'run_command' || tool === 'start_dev_server') pattern = '^' + esc(args?.command) + '$'\n else if (tool === 'open_in_browser') pattern = '^' + esc(args?.url) + '$'\n else pattern = '^' + esc(args?.path) + '$'\n if (!ws.allow.some(r => r.tool === tool && r.pattern === pattern)) {\n ws.allow.push({ tool, pattern })\n savePermissions(permState)\n }\n}\n\nipcMain.handle('perm-respond', (_e, approvalId, decision) => {\n const pend = pendingApprovals.get(approvalId)\n if (!pend) return false\n pendingApprovals.delete(approvalId)\n // Re-arm the chat's idle timer now that the human has answered.\n const pendingChat = pendingRequests.get(pend.workerId)\n if (pendingChat) { pendingChat.awaitingApproval = false; pendingChat.onActivity?.() }\n\n if (decision === 'deny') {\n replyApprovalToWorker(pend.workerId, 'deny', 'You denied this action.')\n return true\n }\n if (decision === 'allow-always' && !pend.floor) {\n addAllowRule(pend.workspacePath, pend.tool, pend.args)\n }\n replyApprovalToWorker(pend.workerId, 'allow')\n return true\n})\n\n// ── IPC: Routines (v0.5.4 as \"Cron Skills\"; renamed v0.8) ─────────────────────────────────────────────\n// Scheduled background chats. Read/write/list/toggle routines; the actual\n// firing happens inside the scheduler started at app-ready, which pipes\n// through the same `chat` IPC so routine runs share provider creds, memory,\n// and the api-worker's tool pipeline.\nipcMain.handle('routines-list', () => routines.list())\nipcMain.handle('routines-upsert', (_e, routine) => routines.upsert(routine))\nipcMain.handle('routines-remove', (_e, id) => { routines.remove(id); return true })\nipcMain.handle('routines-set-enabled', (_e, id, enabled) => routines.setEnabled(id, enabled))\n\n// ── IPC: folder-skills (v0.7.67) ─────────────────────────────────────────\n// Filesystem-discovered skill templates from ~/.labaik/skills/<slug>/SKILL.md.\n// Distinct concept from the cron routines above; see electron/folder-skills.js\n// for the rationale on the naming overlap.\nipcMain.handle('folder-skills-list', () => {\n folderSkills.ensureRoot() // creates dir + README on first call\n return { skills: folderSkills.discover(), root: folderSkills.getRoot() }\n})\nipcMain.handle('folder-skills-get', (_e, slug) => folderSkills.get(slug))\n\n// ── IPC: durable JSON store (v0.7.59) ─────────────────────────────────────\n// Renderer-side stores (memory, profile, eventually sessions) used to live\n// in Chromium localStorage, which batches writes to LevelDB asynchronously\n// and can lose the most recent writes on SIGTERM/crash. These sync channels\n// write through to `~/.labaik/{name}.json` with an atomic tmp+rename so\n// data is durable by the time setItem returns. Small files, <5ms each —\n// the sync block is a fair price for not losing users' memories.\nipcMain.on('fs-json-read-sync', (e, name) => {\n try { e.returnValue = jsonStore.read(name) } catch { e.returnValue = null }\n})\nipcMain.on('fs-json-write-sync', (e, name, data) => {\n try { e.returnValue = jsonStore.write(name, data) } catch { e.returnValue = false }\n})\n\n// ── IPC: MCP (v0.5.6) ─────────────────────────────────────────────────────\nipcMain.handle('mcp-status', () => mcp.listStatus())\nipcMain.handle('mcp-add-server', async (_e, cfg) => {\n const srv = await mcp.addServer(cfg)\n return { name: srv.name, status: srv.status, toolCount: srv.tools.length, error: srv.error }\n})\nipcMain.handle('mcp-remove-server', async (_e, name) => mcp.removeServer(name))\nipcMain.handle('mcp-get-config', () => mcp.loadConfig())\n\n// ── IPC: Key management ─────────────────────────────────────────────────────\n\nipcMain.handle('get-key-statuses', async () => {\n // v0.7.61: PROVIDER_KEY_IDS lives in provider-registry.js so adding\n // a new provider there surfaces it in this status response + the\n // renderer's Keys modal without a hardcoded list to keep in sync.\n const result = {}\n for (const p of PROVIDER_KEY_IDS) {\n const key = getApiKey(p)\n result[p] = key ? 'set' : 'none'\n }\n // Local Ollama runtime counts as a connected \"provider\" for onboarding —\n // a user who has Ollama running with any installed model should not be\n // stuck on the login screen asking for a cloud API key.\n try {\n if (await ollama.isAvailable()) {\n const installed = await ollama.listInstalled()\n result.ollama = (Array.isArray(installed) && installed.length) ? 'set' : 'available'\n } else {\n result.ollama = 'none'\n }\n } catch { result.ollama = 'none' }\n return result\n})\n\nipcMain.handle('set-key', async (_, provider, key) => {\n try {\n saveCredential(provider, key)\n console.log(`[set-key] Saved key for ${provider}`)\n return true\n } catch (err) {\n console.error('[set-key] Failed:', err)\n return false\n }\n})\n\n// ── Ollama local runtime ────────────────────────────────────────────────────\n// Tracks in-flight pulls so the UI can cancel them.\nconst activePulls = new Map() // model -> cancelFn\n\nipcMain.handle('ollama-available', async () => ollama.isAvailable())\n\n// v0.6.0: Semantic memory. Thin wrapper around ollama.embed — embeds a\n// batch of strings via /api/embed. Returns [] if Ollama is down or no\n// embedding model is installed; the renderer then falls back to keyword\n// recall so memory never becomes unavailable.\nipcMain.handle('ollama-embed', async (_e, texts, model) => {\n try {\n if (!(await ollama.isAvailable())) return { ok: false, reason: 'ollama-down' }\n const m = model || (await ollama.findEmbedModel())\n if (!m) return { ok: false, reason: 'no-embed-model' }\n const embeddings = await ollama.embed(texts, m)\n return { ok: true, model: m, embeddings }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\nipcMain.handle('ollama-find-embed-model', async () => {\n try { return await ollama.findEmbedModel() } catch { return null }\n})\n\nipcMain.handle('ollama-list', async () => ollama.listInstalled())\n\n// v0.5.0: in-app Ollama installer. No website trip. Progress streams back\n// to renderer via `ollama-install-progress` events.\nipcMain.handle('ollama-install', async (event) => {\n try {\n const result = await ollama.installOllama({\n onProgress: (p) => {\n try { event.sender.send('ollama-install-progress', p) } catch {}\n },\n })\n return result\n } catch (err) {\n try { event.sender.send('ollama-install-progress', { phase: 'error', pct: null, message: err.message || String(err) }) } catch {}\n throw err\n }\n})\n\nipcMain.handle('ollama-catalog', async () => modelCatalog)\n\nipcMain.handle('ollama-pull', async (event, model) => {\n if (!model || typeof model !== 'string') throw new Error('Model name required')\n if (activePulls.has(model)) throw new Error(`Already pulling ${model}`)\n\n const { promise, cancel } = ollama.pull(model, (progress) => {\n try {\n event.sender.send('model-download-progress', { model, ...progress })\n } catch {}\n })\n activePulls.set(model, cancel)\n\n try {\n await promise\n return { ok: true }\n } catch (err) {\n // AbortError from cancel() — surface as a clean cancel status\n if (err.name === 'AbortError') return { ok: false, cancelled: true }\n throw err\n } finally {\n activePulls.delete(model)\n }\n})\n\nipcMain.handle('ollama-cancel', async (_e, model) => {\n const cancel = activePulls.get(model)\n if (!cancel) return false\n cancel()\n activePulls.delete(model)\n return true\n})\n\nipcMain.handle('ollama-remove', async (_e, model) => {\n await ollama.remove(model)\n return true\n})\n\n// ── File tools ──────────────────────────────────────────────────────────────\n\nconst TOOLS = [\n {\n type: 'function',\n function: {\n name: 'read_file',\n description: 'Read the contents of a file at the given path (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative file path' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'write_file',\n description: 'Write content to a file at the given path (relative to workspace). Creates parent directories if needed.',\n parameters: {\n type: 'object',\n properties: {\n path: { type: 'string', description: 'Relative file path' },\n content: { type: 'string', description: 'File content to write' },\n },\n required: ['path', 'content'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'list_directory',\n description: 'List files and folders in a directory (relative to workspace)',\n parameters: {\n type: 'object',\n properties: { path: { type: 'string', description: 'Relative directory path (use \".\" for workspace root)' } },\n required: ['path'],\n },\n },\n },\n {\n type: 'function',\n function: {\n name: 'run_command',\n description: 'Run a shell command in the workspace directory. Use for npm install, git, build, test, etc.',\n parameters: {\n type: 'object',\n properties: { command: { type: 'string', description: 'Shell command to run' } },\n required: ['command'],\n },\n },\n },\n]\n\nfunction executeToolCall(name, args, workspacePath) {\n const fs = require('fs')\n const { execSync } = require('child_process')\n\n if (!workspacePath) return { error: 'No workspace folder selected. Ask the user to choose a folder.' }\n\n try {\n if (name === 'read_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const content = fs.readFileSync(fullPath, 'utf8')\n return { content: content.slice(0, 50000) } // cap at 50k chars\n }\n\n if (name === 'write_file') {\n const fullPath = path.resolve(workspacePath, args.path)\n if (!fullPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n fs.mkdirSync(path.dirname(fullPath), { recursive: true })\n fs.writeFileSync(fullPath, args.content, 'utf8')\n return { success: true, path: args.path }\n }\n\n if (name === 'list_directory') {\n const dirPath = path.resolve(workspacePath, args.path || '.')\n if (!dirPath.startsWith(workspacePath)) return { error: 'Path outside workspace' }\n const entries = fs.readdirSync(dirPath, { withFileTypes: true })\n return {\n entries: entries\n .filter(e => !e.name.startsWith('.') || e.name === '.env')\n .slice(0, 100)\n .map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`)\n .join('\\n'),\n }\n }\n\n if (name === 'run_command') {\n const output = execSync(args.command, {\n cwd: workspacePath,\n timeout: 30000,\n maxBuffer: 1024 * 1024,\n encoding: 'utf8',\n env: { ...process.env, PATH: `${path.join(os.homedir(), '.bun', 'bin')}:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}` },\n })\n return { output: output.slice(0, 20000) }\n }\n\n return { error: `Unknown tool: ${name}` }\n } catch (err) {\n return { error: err.message || String(err) }\n }\n}\n\n// ── IPC: File handling ──────────────────────────────────────────────────────\n\nipcMain.handle('read-file-for-chat', async (_, filePath) => {\n const fs = require('fs')\n const ext = path.extname(filePath).toLowerCase()\n const name = path.basename(filePath)\n const stats = fs.statSync(filePath)\n const sizeMB = (stats.size / 1024 / 1024).toFixed(1)\n\n // Hard cap — anything bigger freezes the main process while pdf-parse /\n // mammoth / XLSX load the entire buffer.\n const MAX_BYTES = 20 * 1024 * 1024\n if (stats.size > MAX_BYTES) {\n return { type: 'error', name, error: `File too large (${sizeMB} MB > 20 MB limit)` }\n }\n\n try {\n // Images → base64\n if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {\n const data = fs.readFileSync(filePath)\n const base64 = data.toString('base64')\n const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'\n return { type: 'image', name, base64, mime, size: sizeMB }\n }\n\n // PDF\n if (ext === '.pdf') {\n const pdfParse = require('pdf-parse')\n const data = fs.readFileSync(filePath)\n const result = await pdfParse(data)\n return { type: 'document', name, text: result.text, pages: result.numpages, size: sizeMB }\n }\n\n // Word\n if (ext === '.docx') {\n const mammoth = require('mammoth')\n const result = await mammoth.extractRawText({ path: filePath })\n return { type: 'document', name, text: result.value, size: sizeMB }\n }\n\n // CSV / Excel\n if (['.csv', '.xlsx', '.xls'].includes(ext)) {\n const XLSX = require('xlsx')\n const wb = XLSX.readFile(filePath)\n const sheet = wb.Sheets[wb.SheetNames[0]]\n const text = XLSX.utils.sheet_to_csv(sheet)\n const rows = text.split('\\n').length\n return { type: 'data', name, text, rows, size: sizeMB }\n }\n\n // Plain text\n const text = fs.readFileSync(filePath, 'utf8')\n return { type: 'text', name, text: text.slice(0, 100000), size: sizeMB }\n } catch (err) {\n return { type: 'error', name, error: err.message }\n }\n})\n\n// v0.6.0: Screen Vision — macOS screencapture CLI grabs a region (default),\n// the whole screen, or the frontmost window. The PNG path flows through the\n// same read-file-for-chat pipeline so the attachment chip + base64 encode +\n// vision-model routing all work without extra plumbing.\nipcMain.handle('capture-screen', async (_e, mode = 'region') => {\n const { spawn } = require('child_process')\n const fs = require('fs')\n const tmpDir = path.join(os.tmpdir(), 'alaude-screenshots')\n try { fs.mkdirSync(tmpDir, { recursive: true }) } catch {}\n const outPath = path.join(tmpDir, `shot-${Date.now()}.png`)\n const args = ['-x'] // -x: no shutter sound\n if (mode === 'region') args.push('-s') // interactive crosshair select\n else if (mode === 'window') args.push('-Wo') // window pick, no drop shadow\n // else 'screen' — no extra flags → full primary screen\n args.push(outPath)\n\n return new Promise((resolve, reject) => {\n // Hide the window briefly so the user can capture underneath the app.\n const wasVisible = mainWindow && mainWindow.isVisible() && !mainWindow.isMinimized()\n if (wasVisible && mode !== 'screen') {\n try { mainWindow.hide() } catch {}\n }\n const child = spawn('/usr/sbin/screencapture', args, { stdio: 'ignore' })\n child.on('error', (err) => {\n if (wasVisible) try { mainWindow.show() } catch {}\n reject(new Error('screencapture failed: ' + err.message))\n })\n child.on('close', (code) => {\n if (wasVisible) try { mainWindow.show(); mainWindow.focus() } catch {}\n if (code !== 0) {\n // User pressed Esc during -s region select — not an error, just null.\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n if (!fs.existsSync(outPath)) return resolve(null)\n const stats = fs.statSync(outPath)\n if (stats.size < 100) { // likely empty / cancelled\n try { fs.unlinkSync(outPath) } catch {}\n return resolve(null)\n }\n resolve(outPath)\n })\n })\n})\n\nipcMain.handle('pick-file', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openFile'],\n filters: [\n { name: 'All Files', extensions: ['*'] },\n { name: 'Documents', extensions: ['pdf', 'docx', 'txt', 'md'] },\n { name: 'Data', extensions: ['csv', 'xlsx', 'xls', 'json'] },\n { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },\n ]\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\nipcMain.handle('save-file', async (_, content, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.txt',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n fs.writeFileSync(result.filePath, content, 'utf8')\n return result.filePath\n})\n\n// Save a binary buffer (pptx/docx/xlsx/pdf/etc.) via the native save dialog.\n// Returns { path, size, name, mtime } or null if the user cancelled.\nipcMain.handle('save-binary-file', async (_, arrayBuffer, defaultName) => {\n const result = await dialog.showSaveDialog(mainWindow, {\n defaultPath: defaultName || 'output.bin',\n })\n if (result.canceled || !result.filePath) return null\n const fs = require('fs')\n const path = require('path')\n const buf = Buffer.from(arrayBuffer)\n fs.writeFileSync(result.filePath, buf)\n const stat = fs.statSync(result.filePath)\n return {\n path: result.filePath,\n size: stat.size,\n name: path.basename(result.filePath),\n mtime: stat.mtimeMs,\n }\n})\n\n// Open a previously-saved file in its OS default application.\n// Returns true on success; a string error message otherwise.\nipcMain.handle('open-path', async (_, filePath) => {\n if (!filePath) return 'no path'\n const err = await shell.openPath(filePath)\n return err ? err : true\n})\n\n// Reveal a saved file in Finder / Explorer (so the user can see it lived).\nipcMain.handle('show-in-folder', async (_, filePath) => {\n if (!filePath) return false\n try { shell.showItemInFolder(filePath); return true } catch { return false }\n})\n\n// ── IPC: Spaces ─────────────────────────────────────────────────────────────\n\nconst { BUILT_IN_SPACES, getSpaceById } = require('./spaces.js')\nconst spacesStore = require('./spaces-store.js')\n\nipcMain.handle('get-spaces', () => {\n const custom = spacesStore.getCustomSpaces()\n return { builtIn: BUILT_IN_SPACES, custom, activeSpaceId: spacesStore.getActiveSpaceId() }\n})\n\nipcMain.handle('set-active-space', (_, id) => {\n return spacesStore.setActiveSpaceId(id)\n})\n\nipcMain.handle('save-custom-space', (_, space) => {\n return spacesStore.saveCustomSpace(space)\n})\n\nipcMain.handle('delete-custom-space', (_, id) => {\n return spacesStore.deleteCustomSpace(id)\n})\n\nipcMain.handle('get-space-prompt', (_, spaceId) => {\n // Check built-in first, then custom\n const builtIn = getSpaceById(spaceId)\n if (builtIn && builtIn.id === spaceId) return builtIn.systemPromptAddition || ''\n const custom = spacesStore.getCustomSpaces().find(s => s.id === spaceId)\n return custom?.systemPromptAddition || ''\n})\n\n// ── IPC: Folder picker ──────────────────────────────────────────────────────\n\nipcMain.handle('pick-folder', async () => {\n const result = await dialog.showOpenDialog(mainWindow, {\n properties: ['openDirectory'],\n title: 'Choose working directory',\n })\n if (result.canceled || !result.filePaths[0]) return null\n return result.filePaths[0]\n})\n\n// v0.7.32 — \"Looks like existing project\" detector.\n// Returns true if the given folder has any of the markers that a\n// human developer would recognize as an established project. When it\n// does, auto-scope stays off — the user is presumably editing that\n// project, not scratching a new one beside it.\n//\n// Signals checked (any single hit = project):\n// - .git / directory\n// - Common build/package manifests: package.json, Cargo.toml,\n// pyproject.toml, go.mod, Gemfile, composer.json, pnpm-lock.yaml,\n// deno.json, mix.exs\n// - Xcode / iOS projects: *.xcodeproj / *.xcworkspace\n// - README.md AND >10 other files at root (existing prose project)\n//\n// Errors (unreadable dir, missing, etc.) → treated as \"not a project\"\n// so auto-scope can still kick in on a best-effort basis.\nipcMain.handle('task-scope-looks-like-project', async (_e, folderPath) => {\n const fs = require('fs')\n try {\n if (typeof folderPath !== 'string' || !folderPath) return false\n const st = fs.statSync(folderPath)\n if (!st.isDirectory()) return false\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const names = entries.map(e => e.name)\n // Hot-exit markers\n const markers = new Set([\n '.git', 'package.json', 'Cargo.toml', 'pyproject.toml',\n 'go.mod', 'Gemfile', 'composer.json', 'pnpm-lock.yaml',\n 'yarn.lock', 'deno.json', 'mix.exs', 'pubspec.yaml',\n 'CMakeLists.txt', 'Makefile', 'build.gradle', 'pom.xml',\n ])\n for (const n of names) {\n if (markers.has(n)) return true\n if (n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace')) return true\n }\n // README + substantive contents — looks like something already going on.\n const hasReadme = names.some(n => /^README(\\.md|\\.txt)?$/i.test(n))\n if (hasReadme && names.filter(n => !n.startsWith('.')).length > 10) return true\n return false\n } catch {\n return false\n }\n})\n\n// v0.7.40 — server lifecycle IPC.\n// Renderer calls these on new-session / explicit cleanup action.\nipcMain.handle('kill-tracked-servers', async () => {\n const killed = killAllTrackedServers('renderer-request')\n return { killed: killed.length, pids: killed }\n})\nipcMain.handle('list-tracked-servers', async () => {\n // Return a shallow copy — renderer uses this to show a warning before\n // starting a new server on a port it's about to conflict with.\n return trackedServers.map(s => ({ pid: s.pid, port: s.port, startedAt: s.startedAt, workspacePath: s.workspacePath }))\n})\nipcMain.handle('port-in-use', async (_e, port) => {\n // Cheap check via `lsof -i :PORT`. Returns the PID if occupied, null if free.\n const { execSync } = require('child_process')\n try {\n const out = execSync(`lsof -nP -i :${port} -t 2>/dev/null | head -1`, { encoding: 'utf8', timeout: 2000 }).trim()\n return out ? { occupied: true, pid: parseInt(out, 10) } : { occupied: false }\n } catch {\n // lsof missing or exec failed — assume free\n return { occupied: false }\n }\n})\n\n// v0.7.31 — Task Scope.\n// Creates a subfolder under `parent`, returns the absolute path.\n// Hardened against three classes of abuse:\n// 1. `name` must not contain path separators or parent refs (\"..\")\n// 2. resolved target must stay strictly inside `parent` (symlink trap)\n// 3. max 80 chars for the folder name (sane filesystem limit)\n// If a folder of the exact name already exists, returns its path — idempotent,\n// no error. If the name collides but would overwrite a non-directory, errors.\nipcMain.handle('task-scope-create-folder', async (_e, parent, name) => {\n const fs = require('fs')\n try {\n if (typeof parent !== 'string' || !parent) return { ok: false, reason: 'no-parent' }\n if (typeof name !== 'string' || !name) return { ok: false, reason: 'no-name' }\n const cleanName = String(name).trim()\n if (!cleanName || cleanName.length > 80) return { ok: false, reason: 'bad-name' }\n // No separators, no '..', no reserved names on macOS\n if (/[\\\\\\/]|\\.\\./.test(cleanName)) return { ok: false, reason: 'unsafe-name' }\n if (/^(\\.DS_Store|Icon\\r)$/.test(cleanName)) return { ok: false, reason: 'reserved-name' }\n const target = path.resolve(parent, cleanName)\n const parentResolved = path.resolve(parent)\n // Belt + suspenders: ensure target is inside parent after resolution\n if (!target.startsWith(parentResolved + path.sep) && target !== parentResolved) {\n return { ok: false, reason: 'escape' }\n }\n // Parent must exist and be a directory\n if (!fs.existsSync(parentResolved)) return { ok: false, reason: 'parent-missing' }\n const parentStat = fs.statSync(parentResolved)\n if (!parentStat.isDirectory()) return { ok: false, reason: 'parent-not-dir' }\n // Target: create if absent, allow if already a directory (idempotent),\n // fail if a non-directory exists at the path.\n if (fs.existsSync(target)) {\n const st = fs.statSync(target)\n if (!st.isDirectory()) return { ok: false, reason: 'exists-not-dir' }\n return { ok: true, path: target, existed: true }\n }\n fs.mkdirSync(target, { recursive: true })\n return { ok: true, path: target, existed: false }\n } catch (err) {\n return { ok: false, reason: 'error', error: String(err?.message || err) }\n }\n})\n\nipcMain.handle('list-files', async (_, folderPath) => {\n const fs = require('fs')\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n return entries\n .filter(e => !e.name.startsWith('.'))\n .slice(0, 50)\n .map(e => ({ name: e.name, isDir: e.isDirectory() }))\n } catch {\n return []\n }\n})\n\n// Workspace tree: returns up to `maxEntries` files+dirs with sizes, skipping\n// heavy noise directories. Used by the sidebar File Browser.\nconst WORKSPACE_IGNORE = new Set([\n 'node_modules', '.git', '.next', '.nuxt', 'dist', 'build', 'out',\n '__pycache__', '.venv', 'venv', '.mypy_cache', '.pytest_cache',\n 'target', '.gradle', '.idea', '.vscode', '.cache', '.DS_Store',\n])\nipcMain.handle('workspace-list', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n try {\n const entries = fs.readdirSync(folderPath, { withFileTypes: true })\n const out = []\n for (const e of entries) {\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(folderPath, e.name)\n let size = 0\n try {\n if (!e.isDirectory()) size = fs.statSync(full).size\n } catch {}\n out.push({ name: e.name, isDir: e.isDirectory(), size, path: full })\n }\n // Sort: dirs first, then alphabetically\n out.sort((a, b) => (a.isDir === b.isDir) ? a.name.localeCompare(b.name) : (a.isDir ? -1 : 1))\n return { entries: out.slice(0, 200) }\n } catch (err) {\n return { error: err.message || 'read-failed' }\n }\n})\n\n// v0.4.4 — recursive workspace file index for @-mention autocomplete in the\n// composer. Returns workspace-relative file paths (files only, no dirs),\n// honoring WORKSPACE_IGNORE and a hard cap so a giant repo can't hang the\n// walk or flood IPC. Capped at ~4000 files / 8 dir levels — plenty for\n// fuzzy-matching, bounded enough to stay snappy.\nipcMain.handle('workspace-files', async (_, folderPath) => {\n const fs = require('fs')\n const p = require('path')\n if (!folderPath) return { error: 'no-folder' }\n const MAX_FILES = 4000\n const MAX_DEPTH = 8\n const out = []\n let truncated = false\n const walk = (dir, depth) => {\n if (truncated || depth > MAX_DEPTH) return\n let entries\n try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }\n for (const e of entries) {\n if (out.length >= MAX_FILES) { truncated = true; return }\n if (e.name.startsWith('.') && e.name !== '.env.example') continue\n if (WORKSPACE_IGNORE.has(e.name)) continue\n const full = p.join(dir, e.name)\n if (e.isDirectory()) walk(full, depth + 1)\n else if (e.isFile()) out.push(p.relative(folderPath, full))\n }\n }\n try {\n walk(folderPath, 0)\n out.sort((a, b) => a.localeCompare(b))\n return { files: out, truncated }\n } catch (err) {\n return { error: err.message || 'walk-failed' }\n }\n})\n\nipcMain.handle('open-external', async (_, url) => {\n shell.openExternal(url)\n})\n\n// ── Menu Bar Ambient (Tray + Quick Window) ────────────────────────────────\n// A small always-available \"⚡ Labaik Quick\" panel that lives in the macOS\n// menu bar. Lets the user ask a question and get an answer without\n// switching windows. Same chat IPC as the main app, but a minimal UI.\n\nfunction createQuickWindow() {\n if (quickWindow) return quickWindow\n quickWindow = new BrowserWindow({\n width: 360,\n height: 420,\n frame: false,\n resizable: false,\n movable: false,\n show: false,\n alwaysOnTop: true,\n fullscreenable: false,\n transparent: false,\n backgroundColor: '#1a1a1a',\n skipTaskbar: true,\n webPreferences: {\n preload: path.join(__dirname, 'preload-quick.js'),\n nodeIntegration: false,\n contextIsolation: true,\n },\n })\n quickWindow.loadFile(path.join(__dirname, '..', 'renderer', 'quick.html'))\n // Hide on blur so clicking elsewhere makes it disappear — standard\n // menu-bar popover behavior.\n quickWindow.on('blur', () => {\n if (quickWindow && quickWindow.isVisible()) quickWindow.hide()\n })\n quickWindow.webContents.setWindowOpenHandler(({ url }) => {\n if (url && (url.startsWith('http://') || url.startsWith('https://'))) shell.openExternal(url)\n return { action: 'deny' }\n })\n return quickWindow\n}\n\nfunction positionQuickWindow() {\n if (!quickWindow || !tray) return\n const trayBounds = tray.getBounds()\n const winBounds = quickWindow.getBounds()\n // Center the window beneath the tray icon; clamp to screen.\n const display = screen.getDisplayMatching(trayBounds)\n const workArea = display.workArea\n let x = Math.round(trayBounds.x + (trayBounds.width / 2) - (winBounds.width / 2))\n x = Math.max(workArea.x + 8, Math.min(x, workArea.x + workArea.width - winBounds.width - 8))\n const y = process.platform === 'darwin' ? trayBounds.y + trayBounds.height + 4 : trayBounds.y - winBounds.height - 4\n quickWindow.setPosition(x, Math.max(y, workArea.y + 4), false)\n}\n\nfunction toggleQuickWindow() {\n createQuickWindow()\n if (quickWindow.isVisible()) {\n quickWindow.hide()\n } else {\n positionQuickWindow()\n quickWindow.show()\n quickWindow.focus()\n quickWindow.webContents.send('quick-show')\n }\n}\n\nfunction createTray() {\n if (tray) return\n // Build a 16×16 template icon so macOS handles dark/light-mode colors.\n const iconPath = path.join(__dirname, '..', 'build', 'icons', 'icon.png')\n let icon\n try {\n icon = nativeImage.createFromPath(iconPath).resize({ width: 18, height: 18 })\n icon.setTemplateImage(true) // macOS will auto-tint to match the menu bar\n } catch (err) {\n // Fallback: tiny empty image if the icon load fails\n icon = nativeImage.createEmpty()\n }\n tray = new Tray(icon)\n tray.setToolTip('Labaik Quick — ⌘⇧A to toggle')\n tray.on('click', () => toggleQuickWindow())\n tray.on('right-click', () => {\n const menu = Menu.buildFromTemplate([\n { label: 'Ask Labaik…', click: () => toggleQuickWindow() },\n { type: 'separator' },\n { label: 'Open Labaik (full)', click: () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else mainWindow.show()\n }},\n { label: 'Open Future Console', click: () => createFutureWindow() },\n { type: 'separator' },\n { label: 'Quit Labaik', click: () => app.quit() },\n ])\n tray.popUpContextMenu(menu)\n })\n}\n\n// IPC handlers for the quick window\nipcMain.handle('quick-hide', () => { if (quickWindow) quickWindow.hide() })\nipcMain.handle('quick-open-main', () => {\n if (!mainWindow || mainWindow.isDestroyed()) createWindow()\n else { mainWindow.show(); mainWindow.focus() }\n if (quickWindow) quickWindow.hide()\n})\nipcMain.handle('open-future-console', () => {\n createFutureWindow()\n return true\n})\n\n// ── Menu ─────────────────────────────────────────────────────────────────────\n\nfunction buildMenu() {\n const template = [\n ...(process.platform === 'darwin' ? [{\n label: 'Alaude',\n submenu: [\n { role: 'about' },\n { type: 'separator' },\n { role: 'services' },\n { type: 'separator' },\n { role: 'hide' },\n { role: 'hideOthers' },\n { role: 'unhide' },\n { type: 'separator' },\n { role: 'quit' },\n ],\n }] : []),\n {\n label: 'File',\n submenu: [\n { label: 'New Session', accelerator: 'CmdOrCtrl+N', click: () => mainWindow?.webContents.send('new-session') },\n { label: 'Future Console', accelerator: 'CmdOrCtrl+Shift+F', click: () => createFutureWindow() },\n { type: 'separator' },\n { role: 'close' },\n ],\n },\n {\n label: 'Edit',\n submenu: [\n { role: 'undo' }, { role: 'redo' }, { type: 'separator' },\n { role: 'cut' }, { role: 'copy' }, { role: 'paste' }, { role: 'selectAll' },\n ],\n },\n {\n label: 'View',\n submenu: [\n { role: 'zoomIn' }, { role: 'zoomOut' }, { role: 'resetZoom' },\n { type: 'separator' },\n { role: 'togglefullscreen' },\n { type: 'separator' },\n { role: 'toggleDevTools' },\n ],\n },\n {\n label: 'Help',\n submenu: [\n { label: 'Documentation', click: () => shell.openExternal('https://github.com/alaude-ai/alaude#readme') },\n { label: 'Website', click: () => shell.openExternal('https://alaude.ai') },\n ],\n },\n ]\n Menu.setApplicationMenu(Menu.buildFromTemplate(template))\n}\n\n// ── Lifecycle ────────────────────────────────────────────────────────────────\n\napp.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors')\n\napp.whenReady().then(() => {\n buildMenu()\n createWindow()\n // Menu-bar ambient: tray + global shortcut ⌘⇧A to toggle the quick window.\n // Failure to create the tray (e.g. on headless CI) shouldn't crash the app.\n try { createTray() } catch (err) { console.warn('[tray] create failed:', err.message) }\n try {\n const accel = process.platform === 'darwin' ? 'Cmd+Shift+A' : 'Ctrl+Shift+A'\n globalShortcut.register(accel, toggleQuickWindow)\n } catch (err) { console.warn('[shortcut] register failed:', err.message) }\n app.on('activate', () => {\n if (BrowserWindow.getAllWindows().length === 0) createWindow()\n })\n // v0.5.4: kick off the routines scheduler. Fires due routines by running\n // a silent chat turn through the existing worker pipeline and notifying\n // the renderer so it can append the result into a dedicated routines session.\n // v0.5.6: Boot any configured MCP servers. Don't await — a slow server\n // shouldn't hold up the UI; its tools just appear once the handshake\n // completes. Errors surface as toasts via the mcp-status event.\n mcp.startAll().then(results => {\n try { mainWindow?.webContents?.send('mcp-ready', results) } catch {}\n }).catch(err => console.warn('[mcp] startAll failed:', err.message))\n\n try {\n routines.startScheduler(async (routine) => {\n const worker = getWorker()\n const id = ++requestId\n const messageId = `routine_${routine.id}_${Date.now()}`\n return new Promise((resolve) => {\n pendingRequests.set(id, {\n sender: mainWindow?.webContents, // route any activity to main window\n resolve: (result) => {\n const preview = (typeof result === 'string' ? result : JSON.stringify(result)).slice(0, 400)\n routines.recordRun(routine.id, { status: 'ok', resultPreview: preview })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: true, result }) } catch {}\n resolve()\n },\n reject: (err) => {\n routines.recordRun(routine.id, { status: 'error', resultPreview: String(err?.message || err).slice(0, 400) })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: String(err?.message || err) }) } catch {}\n resolve()\n },\n })\n const messagesRaw = [{ role: 'user', content: routine.prompt }]\n const mode = getCurrentMode(null) // routines run with global default mode\n const req = JSON.stringify({ id, messageId, messages: messagesRaw, model: routine.model || '', workspacePath: '', spacePrompt: '', mode }) + '\\n'\n try { worker.stdin.write(req, 'utf8') } catch (err) {\n pendingRequests.delete(id)\n resolve()\n }\n // Shorter cap for routine runs — don't let a stuck routine block the queue.\n setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id)\n routines.recordRun(routine.id, { status: 'timeout', resultPreview: 'Routine timed out after 5 min' })\n try { mainWindow?.webContents?.send('routine-ran', { routine, success: false, error: 'Timed out' }) } catch {}\n resolve()\n }\n }, 5 * 60 * 1000)\n })\n })\n } catch (err) { console.warn('[routines] start failed:', err.message) }\n})\n\napp.on('will-quit', () => {\n try { globalShortcut.unregisterAll() } catch {}\n})\n\napp.on('window-all-closed', () => {\n if (process.platform !== 'darwin') app.quit()\n})\n"}}}}