diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index cf56c6b9..4a987bfe 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -21,7 +21,7 @@ This file is the **canonical inventory** of the IPC surface. When you add a new | `get-projects` | `(showArchived)` | `Project[]` | Sidebar payload. Reads from cache. | | `get-active-sessions` | — | `{sessionId, busy}[]` | Currently open PTY sessions plus each one's live `_cliBusy` flag — see "Busy-state reconciliation" below. | | `get-active-terminals` | — | `Terminal[]` | Active PTY identifiers | -| `open-terminal` | `(id, projectPath, isNew, sessionOptions)` | `{ok, error?, mcpActive}` | Spawn or attach a PTY. | +| `open-terminal` | `(id, projectPath, isNew, sessionOptions)` | `{ok, error?, mcpState, mcpError?}` | Spawn or attach a PTY. | | `session-live-elsewhere` | `(id)` | `{pid, cwd, startedAt} \| null` | Whether another process (another Switchboard, a CLI in a terminal) is running session `id`: a live pid in `~/.claude/sessions/` and no PTY for it in this instance. Called by `openSession` before a resume; see `.ai/contexts/cli-session-state.md` ("Live elsewhere"). | | `sessions-live-elsewhere` | `(ids)` | `{[id]: {pid, cwd, startedAt}}` | The same check for a working-set restore batch, answered from one scan of `~/.claude/sessions/`; ids that are not live are absent, and at most 200 ids are looked up. Called once by `runRestore`; see `.ai/contexts/cli-session-state.md` ("Live elsewhere"). | | `stop-session` | `(id)` | `{ok}` | Kill the PTY for `id`. Local only — a remote-attach session's PTY is the local ssh attach client, so this only detaches it; see `remote-stop-session` for the real remote "stop". | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b62aaf9..50c417cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Fixed +- The IDE Emulation label in a session's terminal header now says whether the CLI is connected: it reads "IDE Emulation" only while it is, "IDE Emulation: waiting for CLI" when Switchboard is listening but the CLI has not connected, and "IDE Emulation: failed" when it could not start for that session, with the reason in its tooltip. A session whose IDE Emulation port was already taken no longer shows the label as if it worked. (#320) - On Windows, the file panel no longer opens or saves a credential file (such as one under `.ssh`) through its 8.3 short name or a `\\?\` path. (#390) ### New - A session's Changes panel also lists the changes in the worktrees its subagents are working in, under a header naming the agent and its branch. Those rows open as read-only diffs; a subagent that works in the session's own directory adds nothing. (#303) diff --git a/docs/ide-emulation.md b/docs/ide-emulation.md index b2dda0c0..7a9f8f26 100644 --- a/docs/ide-emulation.md +++ b/docs/ide-emulation.md @@ -13,8 +13,15 @@ Application → IDE Emulation** and save. It applies to sessions launched or resumed afterwards; running sessions keep what they started with, and the settings page says so when the value changes. It is a global setting only. -While a session is connected, its terminal header shows an **IDE Emulation** -label. +The terminal header says what holds for the session: + +- **IDE Emulation** — the CLI is connected. +- **IDE Emulation: waiting for CLI** — Switchboard is listening but the CLI + has not connected (or has disconnected), so file opens do not reach it. +- **IDE Emulation: failed** — the server could not start (port taken, lock + file not written); the session runs without it, and the tooltip gives the + reason. +- Nothing — IDE Emulation is off for the session. With it off, Switchboard does not register, and `claude` finds your own IDE (VS Code, Cursor, …) the way it does outside Switchboard. diff --git a/eslint.config.js b/eslint.config.js index fcd0e2ff..6f58a747 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -275,7 +275,7 @@ const rendererCrossFileGlobals = { openWorkFile: 'readonly', clearNotifications: 'readonly', clearUnread: 'readonly', - setSessionMcpActive: 'readonly', + setSessionMcpState: 'readonly', setSessionSandboxed: 'readonly', destroySession: 'readonly', launchNewSession: 'readonly', diff --git a/main.js b/main.js index c466942c..5a691823 100644 --- a/main.js +++ b/main.js @@ -17,7 +17,7 @@ if (!app.isPackaged && !process.env.SWITCHBOARD_DATA_DIR) { // getFolderIndexMtimeMs moved to session-cache.js const { appendToOutputBuffer, MAX_BUFFER_SIZE } = require('./output-buffer'); -const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles } = require('./mcp-bridge'); +const { startMcpServer, shutdownMcpServer, shutdownAll: shutdownAllMcp, resolvePendingDiff, rekeyMcpServer, cleanStaleLockFiles, getMcpState } = require('./mcp-bridge'); const { fetchAndTransformUsage } = require('./claude-auth'); // SWITCHBOARD_DATA_DIR isolates a dev/test instance from the installed app: @@ -2339,7 +2339,11 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se mainWindow.webContents.send('terminal-data', sessionId, '\x1b[?25l'); } - return { ok: true, reattached: true, mcpActive: !!session.mcpServer, sandbox: !!session.sandbox }; + return { + ok: true, reattached: true, sandbox: !!session.sandbox, + mcpState: session.mcpError ? 'failed' : getMcpState(session.realSessionId || sessionId), + mcpError: session.mcpError || null, + }; } // see .ai/contexts/session-cache.md ("Remote hosts — tmux attach") @@ -2466,6 +2470,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se let ptyProcess; let mcpServer = null; + let mcpError = null; try { if (isPlainTerminal) { const launch = plainTerminalLaunch({ @@ -2584,6 +2589,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se mcpServer = await startMcpServer(sessionId, [spawnCwd], mainWindow, log); claudeCmd += ' --ide'; } catch (err) { + mcpError = err.message; log.error(`[mcp] Failed to start MCP server for ${sessionId}: ${err.message}`); } } @@ -2655,7 +2661,7 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se // Recorded so a reattach can report it too — the renderer badges sandboxed // sessions, and a reattached session is still inside the same sandbox. sandbox: !!sessionOptions?.sandbox, - mcpServer, _openedAt: Date.now(), + mcpServer, mcpError, _openedAt: Date.now(), // see docs/automation.md — the trigger watcher's politeness guard composerState: createComposerState(), // see .ai/contexts/trigger-watcher.md, "Session handle" @@ -2676,7 +2682,11 @@ ipcMain.handle('open-terminal', async (_event, sessionId, projectPath, isNew, se log.info(`[fork-spawn] tempId=${sessionId} forkFrom=${sessionOptions.forkFrom} folder=${projectFolder} knownFiles=${knownJsonlFiles.size}`); } - return { ok: true, reattached: false, mcpActive: !!mcpServer, sandbox: !!sessionOptions?.sandbox }; + return { + ok: true, reattached: false, sandbox: !!sessionOptions?.sandbox, + mcpState: mcpError ? 'failed' : getMcpState(sessionId), + mcpError, + }; }); // --- IPC: activity-trace (fire-and-forget, opt-in) --- diff --git a/mcp-bridge.js b/mcp-bridge.js index 874fd26b..05c06906 100644 --- a/mcp-bridge.js +++ b/mcp-bridge.js @@ -313,10 +313,10 @@ async function handleGetDiagnostics(entry, rpcId) { * Start an MCP WebSocket server for a session. * @returns {{ port: number, authToken: string }} */ -async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { +async function startMcpServer(sessionId, workspaceFolders, mainWindow, log, options = {}) { ensureIdeDir(); - const port = await findFreePort(); + const port = options.port ?? await findFreePort(); const authToken = crypto.randomUUID(); const wss = new WebSocketServer({ @@ -327,6 +327,15 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { return false; }, }); + try { + await new Promise((resolve, reject) => { + wss.once('listening', resolve); + wss.once('error', reject); + }); + } catch (err) { + try { wss.close(); } catch {} + throw err; + } const lockFilePath = path.join(IDE_DIR, `${port}.lock`); const lockData = JSON.stringify({ @@ -343,7 +352,16 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { if (fs.existsSync(lockFilePath)) { try { fs.chmodSync(lockFilePath, 0o600); } catch {} } - fs.writeFileSync(lockFilePath, lockData, { encoding: 'utf8', mode: 0o600 }); + try { + fs.writeFileSync(lockFilePath, lockData, { encoding: 'utf8', mode: 0o600 }); + if (fs.readFileSync(lockFilePath, 'utf8') !== lockData) { + throw new Error(`lock file ${lockFilePath} does not read back as written`); + } + } catch (err) { + try { wss.close(); } catch {} + try { fs.unlinkSync(lockFilePath); } catch {} + throw err; + } const entry = { sessionId, @@ -372,13 +390,17 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { try { entry.ws.close(); } catch {} } entry.ws = ws; + notifyStatus(entry); ws.on('message', (data) => { handleMessage(entry, data.toString(), log); }); ws.on('close', () => { - if (entry.ws === ws) entry.ws = null; + if (entry.ws === ws) { + entry.ws = null; + notifyStatus(entry); + } log.debug(`[mcp] session=${sessionId} CLI disconnected`); }); @@ -397,6 +419,23 @@ async function startMcpServer(sessionId, workspaceFolders, mainWindow, log) { return { port, authToken }; } +/** + * What holds for a session's IDE emulation: 'connected' while the CLI is + * attached, 'listening' when the server is up and no CLI is attached, 'off' + * when there is no server. + */ +function getMcpState(sessionId) { + const entry = servers.get(sessionId); + if (!entry) return 'off'; + return entry.ws ? 'connected' : 'listening'; +} + +function notifyStatus(entry) { + if (entry.mainWindow && !entry.mainWindow.isDestroyed()) { + entry.mainWindow.webContents.send('mcp-status', entry.sessionId, entry.ws ? 'connected' : 'listening'); + } +} + /** * Shut down the MCP server for a session. */ @@ -489,6 +528,7 @@ function cleanStaleLockFiles(log) { module.exports = { startMcpServer, + getMcpState, shutdownMcpServer, shutdownAll, resolvePendingDiff, diff --git a/preload.js b/preload.js index 1efcb553..850110f1 100644 --- a/preload.js +++ b/preload.js @@ -174,6 +174,9 @@ contextBridge.exposeInMainWorld('api', { onMcpCloseAllDiffs: (callback) => { ipcRenderer.on('mcp-close-all-diffs', (_event, sessionId) => callback(sessionId)); }, + onMcpStatus: (callback) => { + ipcRenderer.on('mcp-status', (_event, sessionId, state) => callback(sessionId, state)); + }, onMcpCloseTab: (callback) => { ipcRenderer.on('mcp-close-tab', (_event, sessionId, diffId) => callback(sessionId, diffId)); }, diff --git a/public/app.js b/public/app.js index 7f656e99..b5f42813 100644 --- a/public/app.js +++ b/public/app.js @@ -1161,7 +1161,7 @@ async function launchNewSession(project, sessionOptions) { return; } syncPtySizeAfterOpen(entry); - if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); + if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); showSession(sessionId); @@ -1235,7 +1235,7 @@ async function openSession(session, customOptions, { automatic = false, live } = } skippedWorkingSetEntries.delete(sessionId); syncPtySizeAfterOpen(entry); - if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); + if (typeof setSessionMcpState === 'function') setSessionMcpState(sessionId, result.mcpState, result.mcpError); setSessionSandboxed(sessionId, result.sandbox); showSession(sessionId); diff --git a/public/dialogs.js b/public/dialogs.js index 79094729..04e61086 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -73,7 +73,7 @@ async function launchScheduleCreator(project) { return; } syncPtySizeAfterOpen(entry); - if (typeof setSessionMcpActive === 'function') setSessionMcpActive(result.sessionId, !!openResult.mcpActive); + if (typeof setSessionMcpState === 'function') setSessionMcpState(result.sessionId, openResult.mcpState, openResult.mcpError); if (typeof setSessionSandboxed === 'function') setSessionSandboxed(result.sessionId, openResult.sandbox); showSession(result.sessionId); pollActiveSessions(); diff --git a/public/file-panel.js b/public/file-panel.js index f24049dc..72715323 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -415,6 +415,12 @@ function wireIpcListeners() { closeDiffByDiffId(sessionId, diffId); }); + if (window.api.onMcpStatus) { + window.api.onMcpStatus((sessionId, mcpState) => { + setSessionMcpState(sessionId, mcpState); + }); + } + if (window.api.onGitChangesFileChanged) { window.api.onGitChangesFileChanged((sessionId, filePath) => { handleChangesFileChanged(sessionId, filePath); @@ -430,15 +436,17 @@ function getSessionState(sessionId) { currentTab: null, panelVisible: false, panelWidth: DEFAULT_PANEL_WIDTH, - mcpActive: false, + mcpState: 'off', + mcpDetail: '', }); } return filePanelState.get(sessionId); } -function setSessionMcpActive(sessionId, active) { +function setSessionMcpState(sessionId, mcpState, detail) { const state = getSessionState(sessionId); - state.mcpActive = active; + state.mcpState = mcpState || 'off'; + state.mcpDetail = detail || ''; if (currentPanelSessionId === sessionId) updateMcpIndicator(); } @@ -755,7 +763,15 @@ function updateMcpIndicator() { return; } const state = filePanelState.get(currentPanelSessionId); - mcpIndicatorEl.style.display = (state && state.mcpActive) ? '' : 'none'; + const mcpState = state ? state.mcpState : 'off'; + const look = MCP_INDICATOR_STATES[mcpState]; + if (!look) { + mcpIndicatorEl.style.display = 'none'; + return; + } + mcpIndicatorEl.textContent = look.text; + mcpIndicatorEl.title = look.title + (mcpState === 'failed' && state.mcpDetail ? ` (${state.mcpDetail})` : ''); + mcpIndicatorEl.style.display = ''; } // ── Panel Rendering ───────────────────────────────────────────────── @@ -1773,6 +1789,12 @@ function classifyDiffLine(line) { let mcpIndicatorEl = null; +const MCP_INDICATOR_STATES = { + connected: { text: 'IDE Emulation', title: 'IDE Emulation is active: the CLI is connected. Go to Global Settings to disable.' }, + listening: { text: 'IDE Emulation: waiting for CLI', title: 'IDE Emulation server is listening but the CLI is not connected, so file opens will not reach Switchboard.' }, + failed: { text: 'IDE Emulation: failed', title: 'IDE Emulation could not start for this session; it runs without it.' }, +}; + function addMcpToggle() { mcpIndicatorEl = document.createElement('span'); mcpIndicatorEl.id = 'ide-emulation-indicator'; diff --git a/test/dom-ide-emulation-indicator.test.js b/test/dom-ide-emulation-indicator.test.js new file mode 100644 index 00000000..28214c3a --- /dev/null +++ b/test/dom-ide-emulation-indicator.test.js @@ -0,0 +1,107 @@ +'use strict'; + +// The IDE Emulation badge shows the state that holds for the session — off, +// failed to start, listening with no CLI attached, or connected — not whether +// a server object exists (#320). + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); + +const PUBLIC_DIR = path.join(__dirname, '..', 'public'); + +const INDEX_HTML = ` + +
+ +