448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
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);
|
|
}
|