diff --git a/package.json b/package.json
index d6cebb0..23dffe9 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"main": "main.js",
"scripts": {
"start": "npm run bundle:codemirror && electron .",
+ "web": "node web-server.js",
"test": "node --test",
"electron": "electron .",
"bundle:codemirror": "esbuild public/codemirror-setup.js --bundle --outfile=public/codemirror-bundle.js --format=iife --platform=browser --minify",
diff --git a/public/index.html b/public/index.html
index b6671bd..65f15e3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -97,6 +97,7 @@
+
diff --git a/public/web-api.js b/public/web-api.js
new file mode 100644
index 0000000..88e2229
--- /dev/null
+++ b/public/web-api.js
@@ -0,0 +1,187 @@
+/**
+ * Web mode API shim — implements the same window.api interface as preload.js
+ * but uses fetch() and WebSocket instead of Electron IPC.
+ *
+ * Loaded by index.html only when window.api has not already been set by
+ * Electron's preload script.
+ */
+(function () {
+ if (window.api) return; // Electron mode — preload already set this up
+
+ // ── Token ────────────────────────────────────────────────────────────
+ // Read token from ?token= query param, localStorage, or prompt the user.
+
+ function getToken() {
+ const u = new URL(location.href);
+ const qToken = u.searchParams.get('token');
+ if (qToken) { localStorage.setItem('sb_token', qToken); return qToken; }
+ return localStorage.getItem('sb_token') || '';
+ }
+
+ function promptToken() {
+ const t = prompt('Enter your Switchboard access token:');
+ if (t) { localStorage.setItem('sb_token', t); return t; }
+ return '';
+ }
+
+ let token = getToken();
+ if (!token) token = promptToken();
+
+ // ── HTTP invoke ──────────────────────────────────────────────────────
+
+ async function invoke(channel, ...args) {
+ const res = await fetch('/api/invoke', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': 'Bearer ' + token,
+ },
+ body: JSON.stringify({ channel, args }),
+ });
+ if (res.status === 401) {
+ token = promptToken();
+ if (token) return invoke(channel, ...args);
+ throw new Error('Unauthorized');
+ }
+ if (!res.ok) throw new Error(`API error ${res.status}`);
+ return res.json();
+ }
+
+ // ── WebSocket ────────────────────────────────────────────────────────
+
+ const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') +
+ location.host + '/?token=' + encodeURIComponent(token);
+
+ let ws = null;
+ let wsReady = false;
+ const wsQueue = [];
+ const eventListeners = {}; // event → [callback, ...]
+
+ function wsSend(msg) {
+ const str = JSON.stringify(msg);
+ if (ws && wsReady) {
+ ws.send(str);
+ } else {
+ wsQueue.push(str);
+ }
+ }
+
+ function connectWs() {
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = () => {
+ wsReady = true;
+ for (const msg of wsQueue.splice(0)) ws.send(msg);
+ };
+
+ ws.onmessage = (e) => {
+ let msg;
+ try { msg = JSON.parse(e.data); } catch { return; }
+ if (msg.type === 'event') {
+ const cbs = eventListeners[msg.event] || [];
+ for (const cb of cbs) cb(...(msg.args || []));
+ }
+ };
+
+ ws.onclose = () => {
+ wsReady = false;
+ // Reconnect after a short back-off
+ setTimeout(connectWs, 2000);
+ };
+
+ ws.onerror = () => { ws.close(); };
+ }
+
+ connectWs();
+
+ function on(event, callback) {
+ if (!eventListeners[event]) eventListeners[event] = [];
+ eventListeners[event].push(callback);
+ }
+
+ // ── window.api ────────────────────────────────────────────────────────
+
+ window.api = {
+ // Invoke (request-response)
+ getPlans: () => invoke('get-plans'),
+ readPlan: (f) => invoke('read-plan', f),
+ savePlan: (fp, c) => invoke('save-plan', fp, c),
+ getStats: () => invoke('get-stats'),
+ refreshStats: () => invoke('refresh-stats'),
+ getUsage: () => invoke('get-usage'),
+ getMemories: () => invoke('get-memories'),
+ readMemory: (fp) => invoke('read-memory', fp),
+ saveMemory: (fp, c) => invoke('save-memory', fp, c),
+ getProjects: (showArchived) => invoke('get-projects', showArchived),
+ getActiveSessions: () => invoke('get-active-sessions'),
+ getActiveTerminals: () => invoke('get-active-terminals'),
+ stopSession: (id) => invoke('stop-session', id),
+ toggleStar: (id) => invoke('toggle-star', id),
+ renameSession: (id, name) => invoke('rename-session', id, name),
+ archiveSession: (id, a) => invoke('archive-session', id, a),
+ openTerminal: (id, pp, isNew, so) => invoke('open-terminal', id, pp, isNew, so),
+ search: (t, q, to) => invoke('search', t, q, to),
+ readSessionJsonl: (id) => invoke('read-session-jsonl', id),
+
+ // Settings
+ getSetting: (k) => invoke('get-setting', k),
+ setSetting: (k, v) => invoke('set-setting', k, v),
+ deleteSetting: (k) => invoke('delete-setting', k),
+ getEffectiveSettings: (pp) => invoke('get-effective-settings', pp),
+ getShellProfiles: () => invoke('get-shell-profiles'),
+
+ browseFolder: () => invoke('browse-folder'),
+ addProject: (pp) => invoke('add-project', pp),
+ removeProject: (pp) => invoke('remove-project', pp),
+ openExternal: (url) => { window.open(url, '_blank', 'noopener'); },
+
+ // Send (fire-and-forget, over WebSocket)
+ sendInput: (id, data) => wsSend({ type: 'terminal-input', sessionId: id, data }),
+ resizeTerminal: (id, cols, rows) => wsSend({ type: 'terminal-resize', sessionId: id, cols, rows }),
+ closeTerminal: (id) => wsSend({ type: 'close-terminal', sessionId: id }),
+
+ // Listeners (server → client, over WebSocket)
+ onTerminalData: (cb) => on('terminal-data', (id, d) => cb(id, d)),
+ onSessionDetected: (cb) => on('session-detected', (tid, rid) => cb(tid, rid)),
+ onProcessExited: (cb) => on('process-exited', (id, code) => cb(id, code)),
+ onTerminalNotification:(cb) => on('terminal-notification',(id, msg) => cb(id, msg)),
+ onCliBusyState: (cb) => on('cli-busy-state', (id, busy) => cb(id, busy)),
+ onSessionForked: (cb) => on('session-forked', (old, next) => cb(old, next)),
+ onProjectsChanged: (cb) => on('projects-changed', () => cb()),
+ onStatusUpdate: (cb) => on('status-update', (text, type) => cb(text, type)),
+
+ // File drag-and-drop — not available in web mode
+ getPathForFile: () => '',
+
+ // Platform
+ platform: (() => {
+ const p = (navigator.userAgentData?.platform || navigator.platform || '').toLowerCase();
+ return p.includes('win') ? 'win32' : p.includes('mac') ? 'darwin' : 'linux';
+ })(),
+
+ // App version
+ getAppVersion: () => invoke('get-app-version'),
+
+ // Auto-updater — not available in web mode
+ updaterCheck: () => Promise.resolve({ available: false, web: true }),
+ updaterDownload: () => Promise.resolve(null),
+ updaterInstall: () => Promise.resolve(null),
+ onUpdaterEvent: () => {},
+
+ // MCP bridge (server → client)
+ onMcpOpenDiff: (cb) => on('mcp-open-diff', (id, did, d) => cb(id, did, d)),
+ onMcpOpenFile: (cb) => on('mcp-open-file', (id, d) => cb(id, d)),
+ onMcpCloseAllDiffs: (cb) => on('mcp-close-all-diffs', (id) => cb(id)),
+ onMcpCloseTab: (cb) => on('mcp-close-tab', (id, did) => cb(id, did)),
+
+ // MCP bridge (client → server, over WebSocket)
+ mcpDiffResponse: (sessionId, diffId, action, editedContent) =>
+ wsSend({ type: 'mcp-diff-response', sessionId, diffId, action, editedContent }),
+
+ readFileForPanel: (fp) => invoke('read-file-for-panel', fp),
+ saveFileForPanel: (fp, c) => invoke('save-file-for-panel', fp, c),
+ watchFile: (fp) => invoke('watch-file', fp),
+ unwatchFile: (fp) => invoke('unwatch-file', fp),
+ onFileChanged: (cb) => on('file-changed', (fp) => cb(fp)),
+ };
+})();
diff --git a/web-server.js b/web-server.js
new file mode 100644
index 0000000..b01e2f9
--- /dev/null
+++ b/web-server.js
@@ -0,0 +1,954 @@
+'use strict';
+
+/**
+ * Switchboard web server mode.
+ *
+ * Starts an HTTP + WebSocket server that serves the existing frontend and
+ * exposes all IPC handlers as HTTP endpoints, with terminal I/O over WebSocket.
+ *
+ * Usage:
+ * node web-server.js [--port 3000] [--host 0.0.0.0] [--token ]
+ *
+ * A bearer token is printed to stdout on startup. Pass it in subsequent
+ * requests as: Authorization: Bearer
+ * or as a query parameter: ?token=
+ */
+
+const http = require('http');
+const path = require('path');
+const fs = require('fs');
+const os = require('os');
+const crypto = require('crypto');
+const pty = require('node-pty');
+const { WebSocketServer } = require('ws');
+const { URL } = require('url');
+
+// ── CLI args ─────────────────────────────────────────────────────────
+
+const argv = process.argv.slice(2);
+function argVal(flag) {
+ const i = argv.indexOf(flag);
+ return i !== -1 && argv[i + 1] ? argv[i + 1] : null;
+}
+const PORT = parseInt(argVal('--port') || process.env.SWITCHBOARD_PORT || '3000', 10);
+const HOST = argVal('--host') || process.env.SWITCHBOARD_HOST || '127.0.0.1';
+const TOKEN = argVal('--token') || process.env.SWITCHBOARD_TOKEN || crypto.randomBytes(24).toString('hex');
+
+// ── Logging ───────────────────────────────────────────────────────────
+
+const log = {
+ info: (...a) => console.log('[info]', ...a),
+ debug: (...a) => process.env.DEBUG ? console.log('[debug]', ...a) : undefined,
+ error: (...a) => console.error('[error]', ...a),
+ warn: (...a) => console.warn('[warn]', ...a),
+};
+
+// ── Module imports (same as main.js) ─────────────────────────────────
+
+const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp,
+ resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge');
+const { fetchAndTransformUsage } = require('./claude-auth');
+const {
+ getMeta, getAllMeta, toggleStar, setName, setArchived,
+ isCachePopulated, getAllCached, getCachedByFolder, getCachedFolder, getCachedSession,
+ upsertCachedSessions, deleteCachedSession, deleteCachedFolder,
+ getFolderMeta, getAllFolderMeta, setFolderMeta,
+ upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder,
+ deleteSearchType, searchByType, isSearchIndexPopulated, searchFtsRecreated,
+ getSetting, setSetting, deleteSetting, closeDb,
+} = require('./db');
+const { discoverShellProfiles, getShellProfiles, resolveShell,
+ isWindows, isWslShell, windowsToWslPath, shellArgs } = require('./shell-profiles');
+const { deriveProjectPath } = require('./derive-project-path');
+
+// ── Constants ────────────────────────────────────────────────────────
+
+const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
+const PLANS_DIR = path.join(os.homedir(), '.claude', 'plans');
+const CLAUDE_DIR = path.join(os.homedir(), '.claude');
+const STATS_CACHE_PATH = path.join(CLAUDE_DIR, 'stats-cache.json');
+const MAX_BUFFER_SIZE = 256 * 1024;
+const PUBLIC_DIR = path.join(__dirname, 'public');
+const NODE_MODS_DIR = path.join(__dirname, 'node_modules');
+
+const SETTING_DEFAULTS = {
+ permissionMode: null,
+ dangerouslySkipPermissions: false,
+ worktree: false,
+ worktreeName: '',
+ chrome: false,
+ preLaunchCmd: '',
+ addDirs: '',
+ visibleSessionCount: 5,
+ sidebarWidth: 340,
+ terminalTheme: 'switchboard',
+ mcpEmulation: false,
+ shellProfile: 'auto',
+};
+
+// ── Active PTY sessions (same structure as main.js) ──────────────────
+
+const activeSessions = new Map();
+
+// ── WebSocket broadcast (replaces mainWindow.webContents.send) ───────
+
+const wsClients = new Set();
+
+function broadcast(event, ...args) {
+ const msg = JSON.stringify({ type: 'event', event, args });
+ for (const ws of wsClients) {
+ if (ws.readyState === 1 /* OPEN */) {
+ ws.send(msg);
+ }
+ }
+}
+
+// ── Fake mainWindow object ────────────────────────────────────────────
+// session-cache, session-transitions, and mcp-bridge all call
+// mainWindow.webContents.send(event, ...args) — we intercept via broadcast().
+
+const mainWindow = {
+ isDestroyed: () => false,
+ webContents: {
+ send: (event, ...args) => broadcast(event, ...args),
+ },
+};
+
+// ── Clean PTY env (same as main.js) ──────────────────────────────────
+
+const cleanPtyEnv = Object.fromEntries(
+ Object.entries(process.env).filter(([k]) =>
+ !k.startsWith('ELECTRON_') &&
+ !k.startsWith('GOOGLE_API_KEY') &&
+ k !== 'NODE_OPTIONS' &&
+ k !== 'ORIGINAL_XDG_CURRENT_DESKTOP' &&
+ k !== 'WT_SESSION'
+ )
+);
+
+// ── Session cache ─────────────────────────────────────────────────────
+
+const sessionCache = require('./session-cache');
+sessionCache.init({
+ PROJECTS_DIR,
+ activeSessions,
+ getMainWindow: () => mainWindow,
+ log,
+ db: {
+ deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession,
+ deleteSearchFolder, deleteSearchSession, upsertSearchEntries,
+ setFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName,
+ },
+});
+const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem,
+ buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus,
+ populateCacheViaWorker } = sessionCache;
+
+// ── Session transitions ───────────────────────────────────────────────
+
+const sessionTransitions = require('./session-transitions');
+sessionTransitions.init({ PROJECTS_DIR, activeSessions, getMainWindow: () => mainWindow, log, rekeyMcpServer });
+const { detectSessionTransitions } = sessionTransitions;
+
+// ── Auth helper ───────────────────────────────────────────────────────
+
+function isAuthorized(req) {
+ const auth = req.headers['authorization'] || '';
+ if (auth.startsWith('Bearer ') && auth.slice(7) === TOKEN) return true;
+ try {
+ const u = new URL(req.url, `http://${req.headers.host}`);
+ if (u.searchParams.get('token') === TOKEN) return true;
+ } catch {}
+ return false;
+}
+
+// ── Static file serving ───────────────────────────────────────────────
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8',
+ '.js': 'application/javascript; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.ico': 'image/x-icon',
+ '.svg': 'image/svg+xml',
+ '.woff': 'font/woff',
+ '.woff2':'font/woff2',
+ '.ttf': 'font/ttf',
+ '.json': 'application/json; charset=utf-8',
+ '.map': 'application/json; charset=utf-8',
+};
+
+function serveStatic(res, filePath) {
+ const ext = path.extname(filePath).toLowerCase();
+ const mime = MIME[ext] || 'application/octet-stream';
+ try {
+ const data = fs.readFileSync(filePath);
+ res.writeHead(200, { 'Content-Type': mime });
+ res.end(data);
+ } catch {
+ res.writeHead(404);
+ res.end('Not found');
+ }
+}
+
+// ── IPC handler logic ─────────────────────────────────────────────────
+// Each function mirrors its ipcMain.handle counterpart in main.js.
+
+function handleGetProjects(showArchived) {
+ try {
+ const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated();
+ if (needsPopulate) { populateCacheViaWorker(); return []; }
+ return buildProjectsFromCache(showArchived);
+ } catch (err) {
+ log.error('get-projects:', err);
+ return [];
+ }
+}
+
+function handleGetPlans() {
+ try {
+ if (!fs.existsSync(PLANS_DIR)) return [];
+ const files = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.md'));
+ const plans = [];
+ for (const file of files) {
+ const filePath = path.join(PLANS_DIR, file);
+ try {
+ const stat = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath, 'utf8');
+ const firstLine = content.split('\n').find(l => l.trim());
+ const title = firstLine && firstLine.startsWith('# ')
+ ? firstLine.slice(2).trim() : file.replace(/\.md$/, '');
+ plans.push({ filename: file, title, modified: stat.mtime.toISOString() });
+ } catch {}
+ }
+ plans.sort((a, b) => new Date(b.modified) - new Date(a.modified));
+ try {
+ deleteSearchType('plan');
+ upsertSearchEntries(plans.map(p => ({
+ id: p.filename, type: 'plan', folder: null,
+ title: p.title,
+ body: fs.readFileSync(path.join(PLANS_DIR, p.filename), 'utf8'),
+ })));
+ } catch {}
+ return plans;
+ } catch (err) { log.error('get-plans:', err); return []; }
+}
+
+function handleReadPlan(filename) {
+ try {
+ const filePath = path.join(PLANS_DIR, path.basename(filename));
+ return { content: fs.readFileSync(filePath, 'utf8'), filePath };
+ } catch (err) { return { content: '', filePath: '' }; }
+}
+
+function handleSavePlan(filePath, content) {
+ try {
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(PLANS_DIR)) return { ok: false, error: 'path outside plans directory' };
+ fs.writeFileSync(resolved, content, 'utf8');
+ return { ok: true };
+ } catch (err) { return { ok: false, error: err.message }; }
+}
+
+function handleGetStats() {
+ try {
+ if (!fs.existsSync(STATS_CACHE_PATH)) return null;
+ return JSON.parse(fs.readFileSync(STATS_CACHE_PATH, 'utf8'));
+ } catch { return null; }
+}
+
+async function handleRefreshStats() {
+ const globalSettings = getSetting('global') || {};
+ const statsProfileId = globalSettings.shellProfile || SETTING_DEFAULTS.shellProfile;
+ const statsShellProfile = resolveShell(statsProfileId);
+ const statsShell = statsShellProfile.path;
+ const statsShellExtraArgs = statsShellProfile.args || [];
+ const ptyEnv = {
+ ...cleanPtyEnv,
+ TERM: 'xterm-256color', COLORTERM: 'truecolor',
+ TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1',
+ };
+
+ function runClaude(args, { timeoutMs = 15000, waitFor = null } = {}) {
+ return new Promise((resolve) => {
+ let output = '', settled = false, trustAccepted = false, sawActivity = false;
+ const finish = () => {
+ if (settled) return; settled = true;
+ try { p.kill(); } catch {}
+ resolve(output);
+ };
+ const claudeCmd = `claude ${args}`;
+ const p = pty.spawn(statsShell, shellArgs(statsShell, claudeCmd, statsShellExtraArgs), {
+ name: 'xterm-256color', cols: 120, rows: 40, cwd: os.homedir(), env: ptyEnv,
+ });
+ const strip = (s) => s.replace(/\x1b\[[^@-~]*[@-~]/g, '').replace(/\x1b\][^\x07]*\x07/g, '').replace(/\x1b[^[\]].?/g, '');
+ p.onData((data) => {
+ output += data;
+ if (!trustAccepted && /trust\s*this\s*folder/i.test(strip(output))) {
+ trustAccepted = true;
+ try { p.write('\r'); } catch {}
+ return;
+ }
+ if (waitFor) { if (waitFor.test(strip(output))) finish(); return; }
+ if (!sawActivity) {
+ const oscTitle = data.match(/\x1b\]0;([^\x07\x1b]*)/);
+ if (oscTitle) {
+ const first = oscTitle[1].charAt(0);
+ if (first.charCodeAt(0) >= 0x2800 && first.charCodeAt(0) <= 0x28FF) sawActivity = true;
+ }
+ } else if (data.includes('\u2733')) finish();
+ });
+ p.onExit(() => finish());
+ setTimeout(finish, timeoutMs);
+ });
+ }
+
+ try {
+ const [, usage] = await Promise.all([
+ runClaude('"/stats"', { waitFor: /streak/i, timeoutMs: 10000 }),
+ fetchAndTransformUsage().catch(() => ({})),
+ ]);
+ let stats = null;
+ try {
+ if (fs.existsSync(STATS_CACHE_PATH)) stats = JSON.parse(fs.readFileSync(STATS_CACHE_PATH, 'utf8'));
+ } catch {}
+ return { stats, usage: usage || {} };
+ } catch (err) { log.error('refresh-stats:', err); return { stats: null, usage: {} }; }
+}
+
+async function handleGetUsage() {
+ try { return await fetchAndTransformUsage() || {}; } catch { return {}; }
+}
+
+function folderToShortPath(folder) {
+ return folder.replace(/^-/, '').split('-').filter(Boolean).slice(-2).join('/');
+}
+
+function scanMdFiles(dir) {
+ const results = [];
+ try {
+ if (!fs.existsSync(dir)) return results;
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (e.isFile() && e.name.endsWith('.md')) {
+ const fp = path.join(dir, e.name);
+ const content = fs.readFileSync(fp, 'utf8').trim();
+ if (content) results.push({ filename: e.name, filePath: fp, modified: fs.statSync(fp).mtime.toISOString() });
+ }
+ }
+ } catch {}
+ return results;
+}
+
+function handleGetMemories() {
+ const global = getSetting('global') || {};
+ const hiddenProjects = new Set(global.hiddenProjects || []);
+ const globalFiles = scanMdFiles(CLAUDE_DIR).map(f => ({ ...f, displayPath: '~/.claude' }));
+ const projects = [];
+ try {
+ if (fs.existsSync(PROJECTS_DIR)) {
+ for (const d of fs.readdirSync(PROJECTS_DIR, { withFileTypes: true }).filter(d => d.isDirectory() && d.name !== '.git')) {
+ const folder = d.name;
+ const folderPath = path.join(PROJECTS_DIR, folder);
+ const projectPath = deriveProjectPath(folderPath, folder);
+ if (projectPath && hiddenProjects.has(projectPath)) continue;
+ const shortName = projectPath
+ ? projectPath.split('/').filter(Boolean).slice(-2).join('/')
+ : folderToShortPath(folder);
+ const files = [], seenPaths = new Set();
+ for (const f of [...scanMdFiles(folderPath), ...scanMdFiles(path.join(folderPath, 'memory'))]) {
+ if (!seenPaths.has(f.filePath)) { files.push({ ...f, displayPath: '~/.claude', source: 'claude-home' }); seenPaths.add(f.filePath); }
+ }
+ if (projectPath) {
+ for (const name of ['CLAUDE.md', 'GEMINI.md', 'agents.md']) {
+ const fp = path.join(projectPath, name);
+ try {
+ if (fs.existsSync(fp) && !seenPaths.has(fp)) {
+ const content = fs.readFileSync(fp, 'utf8').trim();
+ if (content) {
+ files.push({ filename: name, filePath: fp, modified: fs.statSync(fp).mtime.toISOString(), displayPath: shortName + '/', source: 'project' });
+ seenPaths.add(fp);
+ }
+ }
+ } catch {}
+ }
+ const dotClaudeDir = path.join(projectPath, '.claude');
+ for (const f of [...scanMdFiles(dotClaudeDir), ...scanMdFiles(path.join(dotClaudeDir, 'commands'))]) {
+ if (!seenPaths.has(f.filePath)) { files.push({ ...f, displayPath: shortName + '/.claude/', source: 'project' }); seenPaths.add(f.filePath); }
+ }
+ }
+ if (files.length) projects.push({ folder, projectPath: projectPath || '', shortName, files });
+ }
+ }
+ } catch (err) { log.error('get-memories:', err); }
+ projects.sort((a, b) => Math.max(...b.files.map(f => new Date(f.modified))) - Math.max(...a.files.map(f => new Date(f.modified))));
+ try {
+ deleteSearchType('memory');
+ upsertSearchEntries([...globalFiles, ...projects.flatMap(p => p.files)].map(f => ({
+ id: f.filePath, type: 'memory', folder: null,
+ title: (f.displayPath || '') + ' ' + f.filename,
+ body: fs.readFileSync(f.filePath, 'utf8'),
+ })));
+ } catch {}
+ return { global: { files: globalFiles }, projects };
+}
+
+function handleReadMemory(filePath) {
+ try {
+ const resolved = path.resolve(filePath);
+ if (!resolved.endsWith('.md')) return '';
+ if (!resolved.startsWith(os.homedir() + path.sep)) return '';
+ return fs.readFileSync(resolved, 'utf8');
+ } catch { return ''; }
+}
+
+function handleSaveMemory(filePath, content) {
+ try {
+ const resolved = path.resolve(filePath);
+ if (!resolved.endsWith('.md')) return { ok: false, error: 'not a .md file' };
+ if (!fs.existsSync(resolved)) return { ok: false, error: 'file does not exist' };
+ fs.writeFileSync(resolved, content, 'utf8');
+ return { ok: true };
+ } catch (err) { return { ok: false, error: err.message }; }
+}
+
+function handleAddProject(projectPath) {
+ try {
+ const stat = fs.statSync(projectPath);
+ if (!stat.isDirectory()) return { error: 'Path is not a directory' };
+ const global = getSetting('global') || {};
+ if (global.hiddenProjects && global.hiddenProjects.includes(projectPath)) {
+ global.hiddenProjects = global.hiddenProjects.filter(p => p !== projectPath);
+ setSetting('global', global);
+ }
+ const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-');
+ const folderPath = path.join(PROJECTS_DIR, folder);
+ if (!fs.existsSync(folderPath)) fs.mkdirSync(folderPath, { recursive: true });
+ if (!fs.readdirSync(folderPath).some(f => f.endsWith('.jsonl'))) {
+ const seedId = crypto.randomUUID();
+ const now = new Date().toISOString();
+ const line = JSON.stringify({ type: 'user', cwd: projectPath, sessionId: seedId, uuid: crypto.randomUUID(), timestamp: now, message: { role: 'user', content: 'New project' } });
+ fs.writeFileSync(path.join(folderPath, seedId + '.jsonl'), line + '\n');
+ }
+ refreshFolder(folder);
+ notifyRendererProjectsChanged();
+ return { ok: true, folder, projectPath };
+ } catch (err) { return { error: err.message }; }
+}
+
+function handleRemoveProject(projectPath) {
+ try {
+ const global = getSetting('global') || {};
+ const hidden = global.hiddenProjects || [];
+ if (!hidden.includes(projectPath)) hidden.push(projectPath);
+ global.hiddenProjects = hidden;
+ setSetting('global', global);
+ const folder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-');
+ deleteCachedFolder(folder);
+ deleteSearchFolder(folder);
+ deleteSetting('project:' + projectPath);
+ notifyRendererProjectsChanged();
+ return { ok: true };
+ } catch (err) { return { error: err.message }; }
+}
+
+function handleSearch(type, query, titleOnly) {
+ return searchByType(type, query, 50, !!titleOnly);
+}
+
+function handleGetActiveSessions() {
+ const active = [];
+ for (const [id, s] of activeSessions) { if (!s.exited) active.push(id); }
+ return active;
+}
+
+function handleGetActiveTerminals() {
+ const terminals = [];
+ for (const [id, s] of activeSessions) {
+ if (!s.exited && s.isPlainTerminal) terminals.push({ sessionId: id, projectPath: s.projectPath });
+ }
+ return terminals;
+}
+
+function handleStopSession(sessionId) {
+ const s = activeSessions.get(sessionId);
+ if (!s || s.exited) return { ok: false, error: 'not running' };
+ s.pty.kill();
+ return { ok: true };
+}
+
+function handleToggleStar(sessionId) { return { starred: toggleStar(sessionId) }; }
+
+function handleRenameSession(sessionId, name) {
+ setName(sessionId, name || null);
+ const cached = getCachedSession(sessionId);
+ updateSearchTitle(sessionId, 'session', (name ? name + ' ' : '') + (cached?.summary || ''));
+ return { name: name || null };
+}
+
+function handleArchiveSession(sessionId, archived) {
+ const val = archived ? 1 : 0;
+ setArchived(sessionId, val);
+ return { archived: val };
+}
+
+function handleReadSessionJsonl(sessionId) {
+ const folder = getCachedFolder(sessionId);
+ if (!folder) return { error: 'Session not found in cache' };
+ const jsonlPath = path.join(PROJECTS_DIR, folder, sessionId + '.jsonl');
+ try {
+ const entries = [];
+ for (const line of fs.readFileSync(jsonlPath, 'utf-8').split('\n')) {
+ if (line.trim()) try { entries.push(JSON.parse(line)); } catch {}
+ }
+ return { entries };
+ } catch (err) { return { error: err.message }; }
+}
+
+function handleGetSetting(key) { return getSetting(key); }
+function handleSetSetting(key, value) { setSetting(key, value); return { ok: true }; }
+function handleDeleteSetting(key) { deleteSetting(key); return { ok: true }; }
+
+function handleGetShellProfiles() { return getShellProfiles(); }
+
+function handleGetEffectiveSettings(projectPath) {
+ const global = getSetting('global') || {};
+ const project = projectPath ? (getSetting('project:' + projectPath) || {}) : {};
+ const effective = { ...SETTING_DEFAULTS };
+ for (const key of Object.keys(SETTING_DEFAULTS)) {
+ if (global[key] !== undefined && global[key] !== null) effective[key] = global[key];
+ if (project[key] !== undefined && project[key] !== null) effective[key] = project[key];
+ }
+ return effective;
+}
+
+function handleReadFileForPanel(filePath) {
+ try {
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(os.homedir() + path.sep)) return { ok: false, error: 'Access denied' };
+ return { ok: true, content: fs.readFileSync(resolved, 'utf8') };
+ } catch (err) { return { ok: false, error: err.message }; }
+}
+
+function handleSaveFileForPanel(filePath, content) {
+ try {
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(os.homedir() + path.sep)) return { ok: false, error: 'Access denied' };
+ if (!fs.existsSync(resolved)) return { ok: false, error: 'File does not exist' };
+ fs.writeFileSync(resolved, content, 'utf8');
+ return { ok: true };
+ } catch (err) { return { ok: false, error: err.message }; }
+}
+
+const fileWatchers = new Map();
+
+function handleWatchFile(filePath) {
+ const resolved = path.resolve(filePath);
+ if (!resolved.startsWith(os.homedir() + path.sep)) return { ok: false, error: 'Access denied' };
+ if (fileWatchers.has(resolved)) return { ok: true };
+ try {
+ let debounce = null;
+ const watcher = fs.watch(resolved, (eventType) => {
+ if (eventType !== 'change') return;
+ if (debounce) clearTimeout(debounce);
+ debounce = setTimeout(() => broadcast('file-changed', resolved), 300);
+ });
+ fileWatchers.set(resolved, watcher);
+ return { ok: true };
+ } catch (err) { return { ok: false, error: err.message }; }
+}
+
+function handleUnwatchFile(filePath) {
+ const resolved = path.resolve(filePath);
+ const watcher = fileWatchers.get(resolved);
+ if (watcher) { watcher.close(); fileWatchers.delete(resolved); }
+ return { ok: true };
+}
+
+async function handleOpenTerminal(sessionId, projectPath, isNew, sessionOptions) {
+ // Reattach to existing session
+ if (activeSessions.has(sessionId)) {
+ const session = activeSessions.get(sessionId);
+ session.rendererAttached = true;
+ session.firstResize = !session.isPlainTerminal;
+ if (session.altScreen && !session.isPlainTerminal) broadcast('terminal-data', sessionId, '\x1b[?1049h');
+ for (const chunk of session.outputBuffer) broadcast('terminal-data', sessionId, chunk);
+ if (!session.isPlainTerminal) broadcast('terminal-data', sessionId, '\x1b[?25l');
+ return { ok: true, reattached: true, mcpActive: !!session.mcpServer };
+ }
+
+ if (!fs.existsSync(projectPath)) return { ok: false, error: `project directory no longer exists: ${projectPath}` };
+
+ const isPlainTerminal = sessionOptions?.type === 'terminal';
+ const effectiveProfileId = (() => {
+ const g = getSetting('global') || {};
+ const p = projectPath ? (getSetting('project:' + projectPath) || {}) : {};
+ let id = SETTING_DEFAULTS.shellProfile;
+ if (g.shellProfile != null) id = g.shellProfile;
+ if (p.shellProfile != null) id = p.shellProfile;
+ return id;
+ })();
+ const requestedProfile = resolveShell(effectiveProfileId);
+ const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal) ? resolveShell('auto') : requestedProfile;
+ const shell = shellProfile.path;
+ const shellExtraArgs = [...(shellProfile.args || [])];
+ const isWsl = isWslShell(shell);
+ if (isWsl) shellExtraArgs.unshift('--cd', windowsToWslPath(projectPath));
+
+ let knownJsonlFiles = new Set(), sessionSlug = null, projectFolder = null;
+
+ if (!isPlainTerminal) {
+ projectFolder = projectPath.replace(/[/_]/g, '-').replace(/^-/, '-');
+ const claudeProjectDir = path.join(PROJECTS_DIR, projectFolder);
+ if (fs.existsSync(claudeProjectDir)) {
+ try { knownJsonlFiles = new Set(fs.readdirSync(claudeProjectDir).filter(f => f.endsWith('.jsonl'))); } catch {}
+ }
+ if (!isNew) {
+ try {
+ const jsonlPath = path.join(claudeProjectDir, sessionId + '.jsonl');
+ const head = fs.readFileSync(jsonlPath, 'utf8').slice(0, 8000);
+ for (const line of head.split('\n').filter(Boolean)) {
+ const entry = JSON.parse(line);
+ if (entry.slug) { sessionSlug = entry.slug; break; }
+ }
+ } catch {}
+ }
+ }
+
+ let ptyProcess, mcpServer = null;
+ try {
+ if (isPlainTerminal) {
+ const claudeShim = 'claude() { echo "\\033[33mTo start a Claude session, use the + button in the sidebar.\\033[0m"; return 1; }; export -f claude 2>/dev/null;';
+ ptyProcess = pty.spawn(shell, shellArgs(shell, undefined, shellExtraArgs), {
+ name: 'xterm-256color', cols: 120, rows: 30,
+ cwd: isWsl ? os.homedir() : projectPath,
+ env: { ...cleanPtyEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1', CLAUDECODE: '1', ENV: claudeShim, BASH_ENV: claudeShim },
+ });
+ setTimeout(() => { if (!ptyProcess._isDisposed) try { ptyProcess.write(claudeShim + ' clear\n'); } catch {} }, 300);
+ } else {
+ let claudeCmd;
+ if (sessionOptions?.forkFrom) claudeCmd = `claude --resume "${sessionOptions.forkFrom}" --fork-session`;
+ else if (isNew) claudeCmd = `claude --session-id "${sessionId}"`;
+ else claudeCmd = `claude --resume "${sessionId}"`;
+
+ if (sessionOptions) {
+ if (sessionOptions.dangerouslySkipPermissions) claudeCmd += ' --dangerously-skip-permissions';
+ else if (sessionOptions.permissionMode) claudeCmd += ` --permission-mode "${sessionOptions.permissionMode}"`;
+ if (sessionOptions.worktree) { claudeCmd += ' --worktree'; if (sessionOptions.worktreeName) claudeCmd += ` "${sessionOptions.worktreeName}"`; }
+ if (sessionOptions.chrome) claudeCmd += ' --chrome';
+ if (sessionOptions.addDirs) {
+ for (const dir of sessionOptions.addDirs.split(',').map(d => d.trim()).filter(Boolean))
+ claudeCmd += ` --add-dir "${dir}"`;
+ }
+ }
+ if (sessionOptions?.preLaunchCmd) claudeCmd = sessionOptions.preLaunchCmd + ' ' + claudeCmd;
+
+ if (sessionOptions?.mcpEmulation !== false) {
+ try {
+ mcpServer = await startMcpServer(sessionId, [projectPath], mainWindow, log);
+ claudeCmd += ' --ide';
+ } catch (err) { log.error(`[mcp] Failed to start for ${sessionId}: ${err.message}`); }
+ }
+
+ const ptyEnv = { ...cleanPtyEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.6.6', FORCE_COLOR: '3', ITERM_SESSION_ID: '1' };
+ if (mcpServer) ptyEnv.CLAUDE_CODE_SSE_PORT = String(mcpServer.port);
+
+ ptyProcess = pty.spawn(shell, shellArgs(shell, claudeCmd, shellExtraArgs), {
+ name: 'xterm-256color', cols: 120, rows: 30,
+ cwd: isWsl ? os.homedir() : projectPath,
+ env: ptyEnv,
+ });
+ }
+ } catch (err) { return { ok: false, error: `Error spawning PTY: ${err.message}` }; }
+
+ const session = {
+ pty: ptyProcess, rendererAttached: true, exited: false,
+ outputBuffer: [], outputBufferSize: 0, altScreen: false,
+ projectPath, firstResize: true,
+ projectFolder, knownJsonlFiles, sessionSlug,
+ isPlainTerminal, forkFrom: sessionOptions?.forkFrom || null,
+ mcpServer, _openedAt: Date.now(),
+ };
+ activeSessions.set(sessionId, session);
+
+ ptyProcess.onData(data => {
+ const currentId = session.realSessionId || sessionId;
+
+ if (data.includes('\x1b]')) {
+ for (const m of data.matchAll(/\x1b\](\d+);([^\x07\x1b]*)(?:\x07|\x1b\\)/g)) {
+ const code = m[1], payload = m[2].slice(0, 120);
+ if (code === '0') {
+ const firstChar = payload.charAt(0);
+ const isBusy = firstChar.charCodeAt(0) >= 0x2800 && firstChar.charCodeAt(0) <= 0x28FF;
+ const isIdle = firstChar === '\u2733';
+ if (isBusy && !session._cliBusy) { session._cliBusy = true; session._oscIdle = false; broadcast('cli-busy-state', currentId, true); }
+ else if (isIdle && session._cliBusy) { session._cliBusy = false; session._oscIdle = true; broadcast('cli-busy-state', currentId, false); }
+ }
+ }
+ for (const osc9 of data.matchAll(/\x1b\]9;([^\x07\x1b]*)(?:\x07|\x1b\\)/g)) {
+ const payload = osc9[1];
+ if (payload.startsWith('4;')) {
+ const level = payload.split(';')[1];
+ if (level === '0') continue;
+ if ((level === '1' || level === '2' || level === '3') && !session._cliBusy) {
+ session._cliBusy = true; session._oscIdle = false; broadcast('cli-busy-state', currentId, true);
+ }
+ } else { broadcast('terminal-notification', currentId, payload); }
+ }
+ }
+
+ if (data.includes('\x1b[?')) {
+ if (data.includes('\x1b[?1049h') || data.includes('\x1b[?47h')) session.altScreen = true;
+ if (data.includes('\x1b[?1049l') || data.includes('\x1b[?47l')) session.altScreen = false;
+ }
+
+ if (!session._suppressBuffer) {
+ session.outputBuffer.push(data);
+ session.outputBufferSize += data.length;
+ while (session.outputBufferSize > MAX_BUFFER_SIZE && session.outputBuffer.length > 1)
+ session.outputBufferSize -= session.outputBuffer.shift().length;
+ }
+
+ broadcast('terminal-data', currentId, data);
+ });
+
+ ptyProcess.onExit(({ exitCode }) => {
+ session.exited = true;
+ const mcpId = session.realSessionId || sessionId;
+ shutdownMcpServer(mcpId);
+ session.mcpServer = null;
+ const realId = session.realSessionId || sessionId;
+ broadcast('process-exited', realId, exitCode);
+ if (realId !== sessionId && activeSessions.has(sessionId)) broadcast('process-exited', sessionId, exitCode);
+ activeSessions.delete(realId);
+ activeSessions.delete(sessionId);
+ });
+
+ return { ok: true, reattached: false, mcpActive: !!mcpServer };
+}
+
+// ── HTTP request handler ──────────────────────────────────────────────
+
+async function handleRequest(req, res) {
+ const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
+ const pathname = u.pathname;
+
+ // Auth check — skip for root page so browsers can display a login error
+ if (pathname !== '/' && !pathname.startsWith('/node_modules/') && !isAuthorized(req)) {
+ // Allow unauthenticated load of the app shell so the UI can prompt for token
+ const isPublicAsset = pathname.startsWith('/public/') || ['.js', '.css', '.png', '.ico', '.svg', '.woff', '.woff2'].some(e => pathname.endsWith(e));
+ if (!isPublicAsset) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Bearer realm="Switchboard"', 'Content-Type': 'text/plain' });
+ res.end('Unauthorized');
+ return;
+ }
+ }
+
+ // ── Static files ──
+ if (pathname === '/' || pathname === '/index.html') {
+ return serveStatic(res, path.join(PUBLIC_DIR, 'index.html'));
+ }
+ if (pathname.startsWith('/node_modules/')) {
+ const nmFile = path.resolve(path.join(__dirname, pathname));
+ if (!nmFile.startsWith(NODE_MODS_DIR + path.sep)) { res.writeHead(403); res.end('Forbidden'); return; }
+ return serveStatic(res, nmFile);
+ }
+ const localFile = path.resolve(path.join(PUBLIC_DIR, pathname.replace(/^\//, '')));
+ if (!pathname.startsWith('/api/') && localFile.startsWith(PUBLIC_DIR + path.sep) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
+ return serveStatic(res, localFile);
+ }
+
+ // ── API ──
+ if (pathname === '/api/invoke' && req.method === 'POST') {
+ let body = '';
+ req.on('data', d => { body += d; });
+ req.on('end', async () => {
+ let parsed;
+ try { parsed = JSON.parse(body); } catch {
+ res.writeHead(400); res.end('Bad JSON'); return;
+ }
+ const { channel, args = [] } = parsed;
+ let result;
+ try {
+ result = await dispatch(channel, args);
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: err.message }));
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(result));
+ });
+ return;
+ }
+
+ res.writeHead(404);
+ res.end('Not found');
+}
+
+async function dispatch(channel, args) {
+ switch (channel) {
+ case 'get-projects': return handleGetProjects(args[0]);
+ case 'get-plans': return handleGetPlans();
+ case 'read-plan': return handleReadPlan(args[0]);
+ case 'save-plan': return handleSavePlan(args[0], args[1]);
+ case 'get-stats': return handleGetStats();
+ case 'refresh-stats': return handleRefreshStats();
+ case 'get-usage': return handleGetUsage();
+ case 'get-memories': return handleGetMemories();
+ case 'read-memory': return handleReadMemory(args[0]);
+ case 'save-memory': return handleSaveMemory(args[0], args[1]);
+ case 'add-project': return handleAddProject(args[0]);
+ case 'remove-project': return handleRemoveProject(args[0]);
+ case 'search': return handleSearch(args[0], args[1], args[2]);
+ case 'get-active-sessions': return handleGetActiveSessions();
+ case 'get-active-terminals': return handleGetActiveTerminals();
+ case 'stop-session': return handleStopSession(args[0]);
+ case 'toggle-star': return handleToggleStar(args[0]);
+ case 'rename-session': return handleRenameSession(args[0], args[1]);
+ case 'archive-session': return handleArchiveSession(args[0], args[1]);
+ case 'read-session-jsonl': return handleReadSessionJsonl(args[0]);
+ case 'get-setting': return handleGetSetting(args[0]);
+ case 'set-setting': return handleSetSetting(args[0], args[1]);
+ case 'delete-setting': return handleDeleteSetting(args[0]);
+ case 'get-shell-profiles': return handleGetShellProfiles();
+ case 'get-effective-settings': return handleGetEffectiveSettings(args[0]);
+ case 'read-file-for-panel': return handleReadFileForPanel(args[0]);
+ case 'save-file-for-panel': return handleSaveFileForPanel(args[0], args[1]);
+ case 'watch-file': return handleWatchFile(args[0]);
+ case 'unwatch-file': return handleUnwatchFile(args[0]);
+ case 'open-terminal': return handleOpenTerminal(args[0], args[1], args[2], args[3]);
+ case 'browse-folder': return null; // no native dialog in web mode
+ case 'open-external': return null; // browser handles this natively
+ case 'get-app-version': {
+ try { return require('./package.json').version; } catch { return '0.0.0'; }
+ }
+ case 'updater-check': return { available: false, web: true };
+ case 'updater-download': return null;
+ case 'updater-install': return null;
+ default: throw new Error(`Unknown channel: ${channel}`);
+ }
+}
+
+// ── Projects watcher ─────────────────────────────────────────────────
+
+function startProjectsWatcher() {
+ if (!fs.existsSync(PROJECTS_DIR)) return;
+ const pending = new Set();
+ let timer = null;
+ function flush() {
+ timer = null;
+ const folders = new Set(pending); pending.clear();
+ let changed = false;
+ for (const folder of folders) {
+ const fp = path.join(PROJECTS_DIR, folder);
+ if (fs.existsSync(fp)) { detectSessionTransitions(folder); refreshFolder(folder); }
+ else deleteCachedFolder(folder);
+ changed = true;
+ }
+ if (changed) notifyRendererProjectsChanged();
+ }
+ try {
+ const watcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_type, filename) => {
+ if (!filename) return;
+ const parts = filename.split(path.sep);
+ const folder = parts[0];
+ if (!folder || folder === '.git') return;
+ const basename = parts[parts.length - 1];
+ if (parts.length === 1 || basename.endsWith('.jsonl')) {
+ pending.add(folder);
+ if (timer) clearTimeout(timer);
+ timer = setTimeout(flush, 500);
+ }
+ });
+ watcher.on('error', err => log.error('Projects watcher error:', err));
+ } catch (err) { log.error('Failed to start projects watcher:', err); }
+}
+
+// ── Start ─────────────────────────────────────────────────────────────
+
+function start() {
+ if (searchFtsRecreated) populateCacheViaWorker();
+ cleanStaleLockFiles && cleanStaleLockFiles();
+ startProjectsWatcher();
+
+ const server = http.createServer(handleRequest);
+
+ // WebSocket server on same HTTP server
+ const wss = new WebSocketServer({ server });
+
+ wss.on('connection', (ws, req) => {
+ // Auth check for WS (token in query string)
+ try {
+ const u = new URL(req.url, `http://${req.headers.host}`);
+ if (u.searchParams.get('token') !== TOKEN) { ws.close(4001, 'Unauthorized'); return; }
+ } catch { ws.close(4001, 'Unauthorized'); return; }
+
+ wsClients.add(ws);
+
+ ws.on('message', (raw) => {
+ let msg;
+ try { msg = JSON.parse(raw); } catch { return; }
+
+ switch (msg.type) {
+ case 'terminal-input': {
+ const s = activeSessions.get(msg.sessionId);
+ if (s && !s.exited) s.pty.write(msg.data);
+ break;
+ }
+ case 'terminal-resize': {
+ const s = activeSessions.get(msg.sessionId);
+ if (s && !s.exited) {
+ if (s.isPlainTerminal) s._suppressBuffer = true;
+ s.pty.resize(msg.cols, msg.rows);
+ if (s.isPlainTerminal) setTimeout(() => { s._suppressBuffer = false; }, 200);
+ if (s.firstResize && !s.isPlainTerminal) {
+ s.firstResize = false;
+ setTimeout(() => {
+ try { s.pty.resize(msg.cols + 1, msg.rows); setTimeout(() => { try { s.pty.resize(msg.cols, msg.rows); } catch {} }, 50); } catch {}
+ }, 50);
+ }
+ }
+ break;
+ }
+ case 'close-terminal': {
+ const s = activeSessions.get(msg.sessionId);
+ if (s) { s.rendererAttached = false; if (s.exited) activeSessions.delete(msg.sessionId); }
+ break;
+ }
+ case 'mcp-diff-response': {
+ resolvePendingDiff(msg.sessionId, msg.diffId, msg.action, msg.editedContent);
+ break;
+ }
+ }
+ });
+
+ ws.on('close', () => wsClients.delete(ws));
+ ws.on('error', () => wsClients.delete(ws));
+ });
+
+ server.listen(PORT, HOST, () => {
+ console.log('');
+ console.log(' Switchboard web server running');
+ console.log(` URL: http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT}`);
+ console.log(` Token: ${TOKEN}`);
+ console.log('');
+ console.log(' Open the URL in your browser. When prompted, enter the token above.');
+ console.log(' Or append ?token= to the URL to authenticate automatically.');
+ console.log('');
+ });
+
+ process.on('SIGINT', () => shutdown());
+ process.on('SIGTERM', () => shutdown());
+
+ function shutdown() {
+ shutdownAllMcp();
+ for (const [, s] of activeSessions) { if (!s.exited) try { s.pty.kill(); } catch {} }
+ for (const w of fileWatchers.values()) w.close();
+ closeDb();
+ server.close(() => process.exit(0));
+ }
+}
+
+start();