Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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". |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
11 changes: 9 additions & 2 deletions docs/ide-emulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ const rendererCrossFileGlobals = {
openWorkFile: 'readonly',
clearNotifications: 'readonly',
clearUnread: 'readonly',
setSessionMcpActive: 'readonly',
setSessionMcpState: 'readonly',
setSessionSandboxed: 'readonly',
destroySession: 'readonly',
launchNewSession: 'readonly',
Expand Down
18 changes: 14 additions & 4 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

// 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:
Expand Down Expand Up @@ -66,7 +66,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 69 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 69 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler, refusedScheduleBinds, resolveScheduleSandbox, scheduleRegistry } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { SETTING_DEFAULTS } = require('./public/setting-defaults');
Expand Down Expand Up @@ -474,8 +474,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 477 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 477 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 478 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file');

Expand Down Expand Up @@ -2339,7 +2339,11 @@
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")
Expand Down Expand Up @@ -2433,7 +2437,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2440 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down Expand Up @@ -2466,6 +2470,7 @@

let ptyProcess;
let mcpServer = null;
let mcpError = null;
try {
if (isPlainTerminal) {
const launch = plainTerminalLaunch({
Expand Down Expand Up @@ -2584,6 +2589,7 @@
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}`);
}
}
Expand Down Expand Up @@ -2655,7 +2661,7 @@
// 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"
Expand All @@ -2676,7 +2682,11 @@
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) ---
Expand Down
48 changes: 44 additions & 4 deletions mcp-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@
log.debug(`[mcp] session=${entry.sessionId} closeAllDiffTabs`);

// Resolve all pending diffs as TAB_CLOSED
for (const [diffId, pending] of entry.pendingDiffs) {

Check warning on line 290 in mcp-bridge.js

View workflow job for this annotation

GitHub Actions / lint

'diffId' is assigned a value but never used. Allowed unused vars must match /^_/u
pending.resolve({ action: 'accept' });
}
entry.pendingDiffs.clear();
Expand All @@ -313,10 +313,10 @@
* 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({
Expand All @@ -327,6 +327,15 @@
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({
Expand All @@ -343,7 +352,16 @@
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,
Expand Down Expand Up @@ -372,13 +390,17 @@
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`);
});

Expand All @@ -397,6 +419,23 @@
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.
*/
Expand Down Expand Up @@ -489,6 +528,7 @@

module.exports = {
startMcpServer,
getMcpState,
shutdownMcpServer,
shutdownAll,
resolvePendingDiff,
Expand Down
3 changes: 3 additions & 0 deletions preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
},
Expand Down
4 changes: 2 additions & 2 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion public/dialogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
30 changes: 26 additions & 4 deletions public/file-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
}

Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────
Expand Down Expand Up @@ -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';
Expand Down
Loading
Loading