{"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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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- 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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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('

Login failed

Invalid state or missing code. Please try again.

')\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('

Logged in!

You can close this window and return to Labaik.

')\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(`

Error

${err.message}

`)\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//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"}}}}