From 8ee18296c0f39f4ddd6c2aa3b37145e138f44fa4 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:24:43 +0200 Subject: [PATCH 1/2] (mcp): report the IDE emulation state that holds, not that a server exists The header label was driven by "a server object exists". It now reads connected, waiting for CLI, or failed (with the reason), and is updated as the CLI attaches and leaves. A server that cannot listen or whose lock file does not read back rejects the start instead of being reported up. Closes #320 --- CHANGELOG.md | 3 + docs/ide-emulation.md | 11 ++- eslint.config.js | 2 +- main.js | 18 +++- mcp-bridge.js | 48 +++++++++- preload.js | 3 + public/app.js | 4 +- public/dialogs.js | 2 +- public/file-panel.js | 30 ++++++- test/dom-ide-emulation-indicator.test.js | 107 +++++++++++++++++++++++ test/mcp-bridge-status.test.js | 80 +++++++++++++++++ 11 files changed, 290 insertions(+), 18 deletions(-) create mode 100644 test/dom-ide-emulation-indicator.test.js create mode 100644 test/mcp-bridge-status.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 97dae2a5..1cf26f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ 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) + ## v0.0.86 — 2026-10-01 ### New 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 6a2a948c..342f07a3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -273,7 +273,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 795ca5e8..c2fe7343 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: @@ -2277,7 +2277,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") @@ -2404,6 +2408,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({ @@ -2522,6 +2527,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}`); } } @@ -2593,7 +2599,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" @@ -2614,7 +2620,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 c2dd3d0d..78af2a25 100644 --- a/preload.js +++ b/preload.js @@ -173,6 +173,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 7eb13182..31e9b9ca 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 bd30ae51..c377fd7e 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -414,6 +414,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); @@ -429,15 +435,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(); } @@ -754,7 +762,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 ───────────────────────────────────────────────── @@ -1735,6 +1751,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 = ` + + + +
+ + +`; + +function setup() { + const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }); + const { window } = dom; + const calls = { status: null }; + window.api = new Proxy({ + onMcpStatus: (cb) => { calls.status = cb; }, + }, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'string' && prop.startsWith('on')) return () => {}; + return () => Promise.resolve({ ok: true }); + }, + }); + Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + for (const f of ['viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'header-controls.js', 'file-panel.js']) { + vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC_DIR, f) }); + } + window.initFilePanel(); + const badge = () => window.document.getElementById('ide-emulation-indicator'); + return { window, calls, badge, destroy: () => window.close() }; +} + +test('a session with no IDE emulation shows no badge', () => { + const ctx = setup(); + try { + ctx.window.switchPanel('s1'); + ctx.window.setSessionMcpState('s1', 'off'); + assert.equal(ctx.badge().style.display, 'none'); + } finally { ctx.destroy(); } +}); + +test('a listening server whose CLI has not attached says it is waiting, not that IDE emulation works', () => { + const ctx = setup(); + try { + ctx.window.switchPanel('s1'); + ctx.window.setSessionMcpState('s1', 'listening'); + assert.notEqual(ctx.badge().style.display, 'none'); + assert.equal(ctx.badge().textContent, 'IDE Emulation: waiting for CLI'); + assert.match(ctx.badge().title, /not connected/i); + } finally { ctx.destroy(); } +}); + +test('a connected CLI shows the plain IDE Emulation badge', () => { + const ctx = setup(); + try { + ctx.window.switchPanel('s1'); + ctx.window.setSessionMcpState('s1', 'connected'); + assert.notEqual(ctx.badge().style.display, 'none'); + assert.equal(ctx.badge().textContent, 'IDE Emulation'); + } finally { ctx.destroy(); } +}); + +test('a server that could not start shows the failure and its reason', () => { + const ctx = setup(); + try { + ctx.window.switchPanel('s1'); + ctx.window.setSessionMcpState('s1', 'failed', 'EADDRINUSE'); + assert.notEqual(ctx.badge().style.display, 'none'); + assert.equal(ctx.badge().textContent, 'IDE Emulation: failed'); + assert.match(ctx.badge().title, /EADDRINUSE/); + } finally { ctx.destroy(); } +}); + +test('a status pushed from main updates the badge of the session it names, and only that one', () => { + const ctx = setup(); + try { + ctx.window.switchPanel('s1'); + ctx.window.setSessionMcpState('s1', 'listening'); + ctx.window.setSessionMcpState('s2', 'listening'); + + ctx.calls.status('s1', 'connected'); + assert.equal(ctx.badge().textContent, 'IDE Emulation'); + + ctx.calls.status('s2', 'connected'); + ctx.calls.status('s1', 'listening'); + assert.equal(ctx.badge().textContent, 'IDE Emulation: waiting for CLI', 'a disconnect puts the badge back to waiting'); + + ctx.window.switchPanel('s2'); + assert.equal(ctx.badge().textContent, 'IDE Emulation', 'the other session kept its own state'); + } finally { ctx.destroy(); } +}); diff --git a/test/mcp-bridge-status.test.js b/test/mcp-bridge-status.test.js new file mode 100644 index 00000000..ba0f1492 --- /dev/null +++ b/test/mcp-bridge-status.test.js @@ -0,0 +1,80 @@ +'use strict'; + +// What the bridge reports about a session is what holds: listening until the +// CLI attaches, connected while it is attached, and a server that cannot listen +// is a refusal, not a started server (#320). + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const home = fs.mkdtempSync(path.join(os.tmpdir(), 'sb-mcp-status-')); +process.env.HOME = home; +process.env.USERPROFILE = home; + +const WebSocket = require('ws'); +const { startMcpServer, shutdownAll, getMcpState } = require('../mcp-bridge'); + +const log = { info() {}, warn() {}, debug() {}, error() {} }; + +test.after(() => { + shutdownAll(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +function windowSpy(sent) { + return { isDestroyed: () => false, webContents: { send: (...args) => sent.push(args) } }; +} + +function open(port, token) { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, 'mcp', { headers: { 'x-claude-code-ide-authorization': token } }); + return new Promise((resolve, reject) => { ws.once('open', () => resolve(ws)); ws.once('error', reject); }); +} + +const until = async (predicate) => { + for (let i = 0; i < 100 && !predicate(); i++) await new Promise((r) => setTimeout(r, 10)); +}; + +test('a session nobody started a server for is off', () => { + assert.equal(getMcpState('never-started'), 'off'); +}); + +test('a started server is listening, connected once the CLI attaches, and listening again when it leaves', async () => { + const sent = []; + const { port, authToken } = await startMcpServer('s-state', [], windowSpy(sent), log); + assert.equal(getMcpState('s-state'), 'listening'); + assert.deepEqual(sent.filter((a) => a[0] === 'mcp-status'), []); + + const ws = await open(port, authToken); + await until(() => getMcpState('s-state') === 'connected'); + assert.equal(getMcpState('s-state'), 'connected'); + assert.deepEqual(sent.filter((a) => a[0] === 'mcp-status').at(-1), ['mcp-status', 's-state', 'connected']); + + ws.close(); + await until(() => getMcpState('s-state') === 'listening'); + assert.equal(getMcpState('s-state'), 'listening'); + assert.deepEqual(sent.filter((a) => a[0] === 'mcp-status').at(-1), ['mcp-status', 's-state', 'listening']); +}); + +test('a connection with a bad token does not make the session connected', async () => { + const sent = []; + const { port } = await startMcpServer('s-badauth', [], windowSpy(sent), log); + const ws = await open(port, 'not-the-token'); + await new Promise((r) => ws.once('close', r)); + assert.equal(getMcpState('s-badauth'), 'listening'); + assert.deepEqual(sent.filter((a) => a[0] === 'mcp-status'), []); +}); + +test('a port already claimed by another process rejects the start and leaves no lock file behind', async () => { + const peer = net.createServer(); + await new Promise((r) => peer.listen(0, '127.0.0.1', r)); + const { port } = peer.address(); + try { + await assert.rejects(startMcpServer('s-busy', [], windowSpy([]), log, { port }), /EADDRINUSE/); + assert.equal(getMcpState('s-busy'), 'off'); + assert.equal(fs.existsSync(path.join(home, '.claude', 'ide', `${port}.lock`)), false); + } finally { peer.close(); } +}); From a6257fdffc43ab53af8d0532fea78d83d5e623a9 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Thu, 1 Oct 2026 20:47:54 +0200 Subject: [PATCH 2/2] (mcp): follow the open-terminal return shape in tests and docs Update the sandbox-badge source check to the multi-line returns, rename the stale setSessionMcpActive stubs, and document mcpState/mcpError. Refs #320 --- .ai/contexts/ipc-bridge.md | 2 +- test/dom-sandbox-toggle.test.js | 2 +- test/process-exit-status.test.js | 2 +- test/restore-live-elsewhere.test.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index e6988ff2..06911b90 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/test/dom-sandbox-toggle.test.js b/test/dom-sandbox-toggle.test.js index 517b59ca..afb55d77 100644 --- a/test/dom-sandbox-toggle.test.js +++ b/test/dom-sandbox-toggle.test.js @@ -318,7 +318,7 @@ test('package.json: the sandbox script ships in builds and is asar-unpacked so b test('sandbox badge: main.js reports the sandbox state on both open-terminal returns', () => { const src = fs.readFileSync(path.join(ROOT, 'main.js'), 'utf8'); - const returns = src.match(/return \{ ok: true, reattached: (?:true|false)[^}]*\}/g) || []; + const returns = src.match(/return \{\s*ok: true, reattached: (?:true|false)[^}]*\}/g) || []; // Local reattach, local spawn, and remote attach (issue #221) — each must // report the sandbox state so the renderer badge never reads `undefined`. assert.equal(returns.length, 3, 'open-terminal has exactly three success returns'); diff --git a/test/process-exit-status.test.js b/test/process-exit-status.test.js index 256c4ea9..7ddd9cc8 100644 --- a/test/process-exit-status.test.js +++ b/test/process-exit-status.test.js @@ -69,7 +69,7 @@ const PRELUDE = ` async function guardResume() { return true; } async function resolveDefaultSessionOptions() { return {}; } function syncPtySizeAfterOpen() {} - function setSessionMcpActive() {} + function setSessionMcpState() {} function setSessionSandboxed() {} function showSession() { updateTerminalHeader(); } function makeEntry() { return { closed: false, initialSize: null, terminal: { write: (d) => calls.writes.push(d) } }; } diff --git a/test/restore-live-elsewhere.test.js b/test/restore-live-elsewhere.test.js index b3220dc5..90ccf9b0 100644 --- a/test/restore-live-elsewhere.test.js +++ b/test/restore-live-elsewhere.test.js @@ -79,7 +79,7 @@ function setup({ savedSet, liveIds, batchFails = false }) { function destroySession(id) { openSessions.delete(id); } async function resolveDefaultSessionOptions() { return {}; } function syncPtySizeAfterOpen() {} - function setSessionMcpActive() {} + function setSessionMcpState() {} function setSessionSandboxed() {} function forgetSessionExit() {} function schedulePersistWorkingSet() {}