import net from 'node:net'; import path from 'node:path'; import { readFile, realpath } from 'node:fs/promises'; import { decodeMultiStream, encode } from '@msgpack/msgpack'; const OPEN = 1; const DEFAULT_COLUMNS = 190; const DEFAULT_ROWS = 50; const RETRY_DELAY_MS = 1200; const TARGET_POLL_MS = 750; const MAX_BUFFERED_BYTES = 4 * 1024 * 1024; function errorText(error) { return error instanceof Error ? error.message : String(error); } function delay(milliseconds) { return new Promise(resolve => setTimeout(resolve, milliseconds)); } async function selectedTarget(selectionRoot) { const current = path.join(selectionRoot, 'current'); const socketLink = path.join(current, 'n.sock'); const [socketPath, metadataText] = await Promise.all([ realpath(socketLink), readFile(path.join(current, 'selection.json'), 'utf8') ]); const metadata = JSON.parse(metadataText); return { socketPath, metadata, epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}` }; } class NvimRpcClient { constructor(socketPath, onNotification) { this.socketPath = socketPath; this.onNotification = onNotification; this.socket = new net.Socket(); this.nextMessageId = 1; this.pending = new Map(); this.closed = false; this.readLoop = null; } async connect() { await new Promise((resolve, reject) => { const connected = () => { this.socket.off('error', failed); resolve(); }; const failed = error => { this.socket.off('connect', connected); reject(error); }; this.socket.once('connect', connected); this.socket.once('error', failed); this.socket.connect(this.socketPath); }); this.readLoop = this.consume().catch(error => { this.rejectPending(error); if (!this.closed) this.socket.destroy(error); throw error; }); } async consume() { for await (const message of decodeMultiStream(this.socket)) { if (!Array.isArray(message)) continue; if (message[0] === 1) { const pending = this.pending.get(message[1]); if (!pending) continue; this.pending.delete(message[1]); if (message[2]) pending.reject(new Error(JSON.stringify(message[2]))); else pending.resolve(message[3]); } else if (message[0] === 2) { this.onNotification(message[1], message[2]); } } throw new Error('Neovim zamknął połączenie RPC.'); } call(method, parameters = []) { if (this.closed) return Promise.reject(new Error('Połączenie RPC jest zamknięte.')); const id = this.nextMessageId++; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.socket.write(encode([0, id, method, parameters]), error => { if (!error) return; this.pending.delete(id); reject(error); }); }); } rejectPending(error) { for (const pending of this.pending.values()) pending.reject(error); this.pending.clear(); } close() { if (this.closed) return; this.closed = true; this.rejectPending(new Error('Połączenie RPC zostało zamknięte.')); this.socket.destroy(); } } function safeSend(websocket, message) { if (websocket.readyState !== OPEN) return; if (websocket.bufferedAmount > MAX_BUFFERED_BYTES) { websocket.close(1013, 'Neovim UI client is too slow'); return; } websocket.send(JSON.stringify(message)); } function existingUiSize(uis) { const valid = Array.isArray(uis) ? uis.filter(item => Number.isInteger(item?.width) && Number.isInteger(item?.height)) : []; const terminal = valid.find(item => item?.stdin_tty && item?.stdout_tty); if (terminal) { return { width: terminal.width, height: terminal.height }; } return { // Without a terminal UI there is nothing to mirror, so retain a useful // external-UI fallback size until the selected terminal is attached again. width: Math.max(DEFAULT_COLUMNS, ...valid.map(item => item.width)), height: Math.max(DEFAULT_ROWS, ...valid.map(item => item.height)) }; } class NvimUiHub { constructor(selectionRoot, onEmpty) { this.selectionRoot = selectionRoot; this.onEmpty = onEmpty; this.clients = new Set(); this.rpc = null; this.stopped = false; this.started = false; this.status = { type: 'status', state: 'connecting' }; this.pendingResize = null; this.resizeTimer = null; this.appliedUiSize = ''; this.uiSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS }; this.uiAttached = false; this.refreshingUi = null; } broadcast(message) { this.status = message.type === 'status' ? message : this.status; for (const client of this.clients) safeSend(client, message); } add(websocket) { this.clients.add(websocket); safeSend(websocket, this.status); const remove = () => this.remove(websocket); websocket.once('close', remove); websocket.once('error', remove); websocket.on('message', payload => this.handleMessage(payload)); if (!this.started) { this.started = true; void this.run().catch(error => { this.broadcast({ type: 'status', state: 'offline', message: errorText(error) }); }); } else if (this.rpc && this.uiAttached) { // A newly opened browser has no copy of redraw events emitted before it // joined the shared hub. Reattaching only this external UI makes Neovim // send one complete line-grid frame. The real TTY UI stays attached. void this.refreshUi().catch(() => undefined); } } refreshUi() { if (this.refreshingUi) return this.refreshingUi; const rpc = this.rpc; const size = this.uiSize; if (!rpc || !this.uiAttached) return Promise.resolve(); this.refreshingUi = (async () => { await rpc.call('nvim_ui_detach'); if (rpc !== this.rpc || this.stopped) return; this.uiAttached = false; await rpc.call('nvim_ui_attach', [size.width, size.height, { rgb: false, ext_linegrid: true, ext_multigrid: false, override: false }]); if (rpc === this.rpc) this.uiAttached = true; })().finally(() => { this.refreshingUi = null; }); return this.refreshingUi; } remove(websocket) { this.clients.delete(websocket); if (this.clients.size > 0) return; this.stopped = true; if (this.resizeTimer) clearTimeout(this.resizeTimer); this.rpc?.close(); this.onEmpty(); } scheduleResize(columns, rows) { this.pendingResize = { columns, rows }; if (this.resizeTimer) clearTimeout(this.resizeTimer); this.resizeTimer = setTimeout(() => { this.resizeTimer = null; void this.applyResize().catch(() => undefined); }, 90); } async resizeToTerminal() { if (this.resizeTimer) { clearTimeout(this.resizeTimer); this.resizeTimer = null; } this.pendingResize = { columns: this.uiSize.width, rows: this.uiSize.height }; await this.applyResize(); return this.uiSize; } async applyResize() { const requested = this.pendingResize; const rpc = this.rpc; this.pendingResize = null; if (!requested || !rpc || this.stopped) return; const uis = await rpc.call('nvim_list_uis'); if (rpc !== this.rpc) return; const terminalUi = Array.isArray(uis) ? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty) : null; const columns = Number.isInteger(terminalUi?.width) ? terminalUi.width : requested.columns; const rows = Number.isInteger(terminalUi?.height) ? terminalUi.height : requested.rows; const size = `${columns}x${rows}`; if (size === this.appliedUiSize) return; await rpc.call('nvim_ui_try_resize', [columns, rows]); if (rpc !== this.rpc) return; await rpc.call('nvim_command', ['silent! StudentLayoutFit']); this.uiSize = { width: columns, height: rows }; this.appliedUiSize = size; } handleMessage(payload) { if (!this.rpc || this.stopped) return; try { const message = JSON.parse(payload.toString('utf8')); if (message?.type === 'refresh') { void this.resizeToTerminal() .then(() => this.refreshUi()) .catch(() => undefined); return; } if (message?.type === 'input' && typeof message.keys === 'string' && message.keys.length <= 256) { void this.rpc.call('nvim_input', [message.keys]).catch(() => undefined); return; } if (message?.type !== 'mouse') return; const button = ['left', 'middle', 'right', 'wheel'].includes(message.button) ? message.button : null; const action = ['press', 'drag', 'release', 'up', 'down'].includes(message.action) ? message.action : null; const row = Number(message.row); const column = Number(message.column); if (!button || !action || !Number.isInteger(row) || !Number.isInteger(column)) return; void this.rpc.call('nvim_input_mouse', [ button, action, typeof message.modifier === 'string' ? message.modifier.slice(0, 16) : '', 0, Math.max(0, row), Math.max(0, column) ]).catch(() => undefined); } catch { // Only the typed input/mouse/refresh capabilities above are exposed. } } async run() { while (!this.stopped && this.clients.size > 0) { let selected; try { selected = await selectedTarget(this.selectionRoot); } catch (error) { this.broadcast({ type: 'status', state: 'unselected', message: `Brak aktywnego celu MCP: ${errorText(error)}` }); await delay(RETRY_DELAY_MS); continue; } this.broadcast({ type: 'status', state: 'connecting', epoch: selected.epoch, target: selected.metadata }); let targetTimer = null; try { this.rpc = new NvimRpcClient(selected.socketPath, (method, parameters) => { if (method !== 'redraw') return; this.broadcast({ type: 'redraw', epoch: selected.epoch, events: parameters }); }); await this.rpc.connect(); this.appliedUiSize = ''; this.uiAttached = false; void this.rpc.readLoop.catch(() => undefined); const uis = await this.rpc.call('nvim_list_uis'); const size = existingUiSize(uis); this.uiSize = size; await this.rpc.call('nvim_set_client_info', [ 'stem-card-browser', { major: 0, minor: 1, patch: 0 }, 'ui', {}, { website: 'local://stem-card', license: 'private' } ]); await this.rpc.call('nvim_ui_attach', [size.width, size.height, { rgb: false, ext_linegrid: true, ext_multigrid: false, override: false }]); this.uiAttached = true; this.broadcast({ type: 'status', state: 'connected', epoch: selected.epoch, target: selected.metadata, grid: size, control: 'typed-input' }); const targetChanged = new Promise((_, reject) => { targetTimer = setInterval(async () => { try { const current = await selectedTarget(this.selectionRoot); if (current.epoch !== selected.epoch) { reject(new Error('Zmieniono cel MCP.')); } } catch (error) { reject(error); } }, TARGET_POLL_MS); }); await Promise.race([this.rpc.readLoop, targetChanged]); } catch (error) { if (!this.stopped) { this.broadcast({ type: 'status', state: 'offline', epoch: selected.epoch, target: selected.metadata, message: errorText(error) }); } } finally { if (targetTimer) clearInterval(targetTimer); this.uiAttached = false; this.rpc?.close(); this.rpc = null; } if (!this.stopped) await delay(RETRY_DELAY_MS); } } } const hubs = new Map(); function selectionRootFor(options = {}) { return options.selectionRoot ?? process.env.MCP_CONTAINER_SELECTION_ROOT ?? path.join(process.env.XDG_RUNTIME_DIR ?? '/run/user/1000', 'stem/mcp-selected'); } export async function callSelectedNvim(method, parameters = [], options = {}) { const selectionRoot = selectionRootFor(options); const hub = hubs.get(selectionRoot); const pinnedSocketPath = options.socketPath ? await realpath(options.socketPath) : null; if ( hub?.rpc && !hub.stopped && (!pinnedSocketPath || hub.rpc.socketPath === pinnedSocketPath) ) { return hub.rpc.call(method, parameters); } const socketPath = pinnedSocketPath ?? (await selectedTarget(selectionRoot)).socketPath; const rpc = new NvimRpcClient(socketPath, () => undefined); await rpc.connect(); void rpc.readLoop.catch(() => undefined); try { return await rpc.call(method, parameters); } finally { rpc.close(); } } export async function resizeSelectedNvimUi(options = {}) { const selectionRoot = selectionRootFor(options); const hub = hubs.get(selectionRoot); if (!hub?.rpc || hub.stopped || !hub.uiAttached) { return { attached: false, grid: null }; } const grid = await hub.resizeToTerminal(); await hub.refreshUi(); return { attached: true, grid }; } export async function selectedNvimTarget(options = {}) { return selectedTarget(selectionRootFor(options)); } /** * Attach a browser to the one shared external UI for the selected container. * The browser never receives the Unix socket and cannot issue arbitrary RPC. */ export function attachNvimUi(websocket, options = {}) { const selectionRoot = selectionRootFor(options); let hub = hubs.get(selectionRoot); if (!hub || hub.stopped) { hub = new NvimUiHub(selectionRoot, () => hubs.delete(selectionRoot)); hubs.set(selectionRoot, hub); } hub.add(websocket); }