mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-06 06:02:37 +08:00
[feat] add MCP terminal views and agent cursor overlays
This commit is contained in:
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { Failure, token, equal, failure, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { uiAction } from "./ui-actions.js";
|
||||
import { displayEnvironment } from "./environment.js";
|
||||
|
||||
const uiFile = fileURLToPath(new URL("./viewer-browser.html", import.meta.url));
|
||||
interface BrowserSession {
|
||||
@@ -25,12 +26,7 @@ interface BrowserSession {
|
||||
expires: number;
|
||||
}
|
||||
export async function openURL(url: string): Promise<boolean> {
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!process.env.DISPLAY &&
|
||||
!process.env.WAYLAND_DISPLAY
|
||||
)
|
||||
return false;
|
||||
if (!displayEnvironment().gui) return false;
|
||||
const command =
|
||||
process.platform === "darwin"
|
||||
? "open"
|
||||
|
||||
@@ -44,7 +44,7 @@ export const alias = z
|
||||
.max(80)
|
||||
.regex(/^[\p{L}\p{N} _.-]+$/u);
|
||||
export const mode = z.enum(["observe", "control"]);
|
||||
export const panel = z.enum(["auto", "apps", "browser", "off"]);
|
||||
export const panel = z.enum(["auto", "apps", "browser", "terminal", "off"]);
|
||||
export type Panel = z.infer<typeof panel>;
|
||||
export const videoOptions = z.strictObject({
|
||||
decode_path: z.enum(["browser", "minirtc"]).default("browser"),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { statSync } from "node:fs";
|
||||
|
||||
// MCP stdio is normally piped even when its host is an interactive terminal.
|
||||
// Detect the desktop independently, without opening or taking over /dev/tty.
|
||||
export function displayEnvironment(
|
||||
platform = process.platform,
|
||||
env = process.env,
|
||||
tty = !!(process.stdin.isTTY || process.stdout.isTTY || process.stderr.isTTY),
|
||||
): { gui: boolean; terminal: boolean } {
|
||||
const ssh = !!(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
||||
let gui = false;
|
||||
if (platform === "linux") gui = !!(env.DISPLAY || env.WAYLAND_DISPLAY);
|
||||
else if (platform === "darwin" && !ssh) {
|
||||
try {
|
||||
const uid = statSync("/dev/console").uid;
|
||||
gui = uid !== 0 && uid === process.getuid?.();
|
||||
} catch { /* No logged-in desktop session. */ }
|
||||
} else if (platform === "win32") {
|
||||
gui = !ssh && env.SESSIONNAME?.toLowerCase() !== "services";
|
||||
}
|
||||
return {
|
||||
gui,
|
||||
terminal: tty || !!env.SSH_TTY || (!!env.TERM && env.TERM !== "dumb"),
|
||||
};
|
||||
}
|
||||
+13
-3
@@ -10,6 +10,8 @@ import { DeviceStore, type Device } from "./store.js";
|
||||
import { Sessions } from "./sessions.js";
|
||||
import { BrowserPanel, pairBrowser, openURL } from "./browser.js";
|
||||
import { createServer } from "./server.js";
|
||||
import { TerminalPanel } from "./terminal.js";
|
||||
import { terminalView } from "./terminal-view.js";
|
||||
import {
|
||||
panel,
|
||||
parse,
|
||||
@@ -120,6 +122,7 @@ async function main() {
|
||||
"native-binary": { type: "string" },
|
||||
"open-panel": { type: "boolean" },
|
||||
view: { type: "string" },
|
||||
"cell-aspect": { type: "string" },
|
||||
help: { type: "boolean" },
|
||||
version: { type: "boolean" },
|
||||
},
|
||||
@@ -130,16 +133,21 @@ async function main() {
|
||||
}
|
||||
if (values.help || !positionals.length) {
|
||||
process.stdout.write(
|
||||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||
"crossdesk-mcp serve --stdio [--panel auto|apps|browser|terminal|off] [--video-path browser|minirtc] [--video-decoder auto|software] [--config-dir directory]\ncrossdesk-mcp view --view VIEW_ID [--config-dir directory] [--cell-aspect 0.5]\ncrossdesk-mcp setup [--config-dir directory]\ncrossdesk-mcp setup --open-panel --view VIEW_ID [--config-dir directory]\nDevelopment: --native-binary /absolute/path/to/crossdesk-cli\n",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (positionals.length !== 1 || !["serve", "setup"].includes(positionals[0]!))
|
||||
if (positionals.length !== 1 || !["serve", "setup", "view"].includes(positionals[0]!))
|
||||
throw new Failure("INVALID_COMMAND");
|
||||
const store = new DeviceStore(
|
||||
values["config-dir"] ?? process.env.CROSSDESK_MCP_CONFIG_DIR,
|
||||
);
|
||||
await store.initialize();
|
||||
if (positionals[0] === "view") {
|
||||
if (!values.view) throw new Failure("VIEW_REQUIRED");
|
||||
await terminalView(store.directory, values.view, Number(values["cell-aspect"] ?? 0.5));
|
||||
return;
|
||||
}
|
||||
if (positionals[0] === "setup") {
|
||||
if (values["open-panel"] && !values.view)
|
||||
throw new Failure("VIEW_REQUIRED");
|
||||
@@ -162,7 +170,8 @@ async function main() {
|
||||
}),
|
||||
);
|
||||
const browser = new BrowserPanel(sessions);
|
||||
const handle = serveStdio(() => createServer(sessions, browser), {
|
||||
const terminal = new TerminalPanel(sessions);
|
||||
const handle = serveStdio(() => createServer(sessions, browser, terminal), {
|
||||
transport: new StdioServerTransport(),
|
||||
onerror: () => process.stderr.write("crossdesk-mcp: protocol error\n"),
|
||||
});
|
||||
@@ -170,6 +179,7 @@ async function main() {
|
||||
const close = async () => {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
await terminal.close();
|
||||
await browser.close();
|
||||
await sessions.close();
|
||||
await handle.close();
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function nativeBinary(override?: string): Promise<string> {
|
||||
});
|
||||
const doctor = JSON.parse(stdout);
|
||||
if (doctor.automation_protocol !== "crossdesk-automation/1" ||
|
||||
doctor.automation_terminal_protocol !== "ansi/1" ||
|
||||
doctor.automation_video_transport !== "loopback-websocket/1" ||
|
||||
!["h264-annexb/1", "nv12/1"].every((p) => doctor.automation_video_protocols?.includes(p)) ||
|
||||
!["auto", "software"].every((p) => doctor.automation_video_decoders?.includes(p)))
|
||||
@@ -144,6 +145,7 @@ export class NativeClient {
|
||||
signal,
|
||||
);
|
||||
if (hello.protocol !== "crossdesk-automation/1" ||
|
||||
hello.terminal_protocol !== "ansi/1" ||
|
||||
hello.video_transport !== "loopback-websocket/1" ||
|
||||
!hello.video_protocols?.includes(this.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") ||
|
||||
!hello.video_decoders?.includes(this.videoOptions.decoder))
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@modelcontextprotocol/ext-apps/server";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { BrowserPanel } from "./browser.js";
|
||||
import { TerminalPanel } from "./terminal.js";
|
||||
import { uiAction, uiSchemas, type UIAction } from "./ui-actions.js";
|
||||
import {
|
||||
id,
|
||||
@@ -60,6 +61,7 @@ function output(value: Data, meta?: Data): CallToolResult {
|
||||
export function createServer(
|
||||
sessions: Sessions,
|
||||
browser: BrowserPanel,
|
||||
terminal: TerminalPanel,
|
||||
): McpServer {
|
||||
const server = new McpServer(
|
||||
{ name: "crossdesk-mcp", version: "0.1.0" },
|
||||
@@ -91,7 +93,9 @@ export function createServer(
|
||||
);
|
||||
};
|
||||
const apps = (ctx: ServerContext) =>
|
||||
["auto", "apps"].includes(sessions.policy) && supported(ctx);
|
||||
sessions.policy === "apps" && supported(ctx);
|
||||
const openPanel = async (v: View) => v.panel === "browser" ? browser.open(v)
|
||||
: v.panel === "terminal" ? terminal.open(v) : sessions.describe(v);
|
||||
const viewResult = (v: View, result: Data) =>
|
||||
output(
|
||||
result,
|
||||
@@ -147,7 +151,7 @@ export function createServer(
|
||||
add(
|
||||
"crossdesk_open",
|
||||
z.strictObject({ device_alias: alias.optional() }),
|
||||
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. Returns immediately so the panel can render. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||
"Open CrossDesk so the user can choose a device, enter its password, and authorize this session. GUI environments use a browser; headless terminal environments return terminal_command, which you must show to the user to run in a separate terminal. Never run the terminal viewer in a captured tool-output channel. Returns immediately. To continue a pending remote task, call crossdesk_get_session with the returned view_id and wait_ms: 25000; no chat confirmation is needed.",
|
||||
async (p, ctx) => {
|
||||
if (p.device_alias) await sessions.store.device(p.device_alias);
|
||||
const v = sessions.create(
|
||||
@@ -156,7 +160,7 @@ export function createServer(
|
||||
);
|
||||
return viewResult(
|
||||
v,
|
||||
v.panel === "browser" ? await browser.open(v) : sessions.describe(v),
|
||||
await openPanel(v),
|
||||
);
|
||||
},
|
||||
{ ui: true },
|
||||
@@ -209,9 +213,7 @@ export function createServer(
|
||||
v.session = result.session_id;
|
||||
return viewResult(v, {
|
||||
...result,
|
||||
...(selected === "browser"
|
||||
? await browser.open(v)
|
||||
: sessions.describe(v)),
|
||||
...await openPanel(v),
|
||||
state: result.state,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "./common.js";
|
||||
import { DeviceStore, type Device } from "./store.js";
|
||||
import { nativeBinary, NativeClient } from "./native.js";
|
||||
import { displayEnvironment } from "./environment.js";
|
||||
|
||||
export interface View {
|
||||
id: string;
|
||||
@@ -46,7 +47,9 @@ export class Sessions {
|
||||
) {}
|
||||
choose(apps: boolean): View["panel"] {
|
||||
if (this.policy === "apps" && !apps) throw new Failure("PANEL_UNSUPPORTED");
|
||||
return this.policy === "auto" ? (apps ? "apps" : "browser") : this.policy;
|
||||
if (this.policy !== "auto") return this.policy;
|
||||
const environment = displayEnvironment();
|
||||
return environment.gui ? "browser" : environment.terminal ? "terminal" : "off";
|
||||
}
|
||||
create(panel: View["panel"], alias?: string): View {
|
||||
for (const [id, view] of this.views)
|
||||
@@ -383,6 +386,12 @@ export class Sessions {
|
||||
operation,
|
||||
});
|
||||
}
|
||||
async terminalFrame(v: View, args: Data, signal?: AbortSignal) {
|
||||
if (v.panel !== "terminal" || !v.session || this.connecting)
|
||||
throw new Failure("NOT_READY");
|
||||
const s = this.session(v.session);
|
||||
return s.native.request("terminal_frame", args, 5000, signal);
|
||||
}
|
||||
async openVideo(v: View, origin: string): Promise<Data> {
|
||||
if (!v.session || this.views.get(v.id) !== v) throw new Failure("NOT_READY");
|
||||
const s = this.session(v.session);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { emitKeypressEvents } from "node:readline";
|
||||
import { Writable } from "node:stream";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Failure, failure, type Data } from "./common.js";
|
||||
import { TerminalClient } from "./terminal.js";
|
||||
|
||||
async function connect(client: TerminalClient) {
|
||||
let hidden = false;
|
||||
const output = new Writable({ write(chunk, _encoding, done) {
|
||||
if (!hidden) process.stdout.write(chunk);
|
||||
done();
|
||||
} });
|
||||
const rl = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
||||
const ask = async (label: string) => (await rl.question(label)).trim();
|
||||
try {
|
||||
const { devices, defaults } = await client.call("devices");
|
||||
const status = await client.call("status");
|
||||
process.stdout.write("CrossDesk MCP · 终端画面\n");
|
||||
for (const d of devices)
|
||||
process.stdout.write(`${d.device_alias} (${d.device_id})\n`);
|
||||
const selected = (await ask(`设备别名或 9 位 ID${status.device_alias ? ` [${status.device_alias}]` : ""}:`)) || status.device_alias;
|
||||
const device = devices.find((d: Data) => d.device_alias === selected);
|
||||
const args: Data = device ? { device_alias: selected } : {
|
||||
device_id: selected,
|
||||
server: (await ask(`服务器 [${defaults.server}]:`)) || defaults.server,
|
||||
port: Number((await ask(`端口 [${defaults.port}]:`)) || defaults.port),
|
||||
};
|
||||
args.mode = (await ask("允许 Agent 操作?[y/N]:")).toLowerCase() === "y" ? "control" : "observe";
|
||||
args.display_index = Number((await ask("显示器索引 [0]:")) || "0");
|
||||
const saved = device?.credential_provider !== "none" && !!device;
|
||||
if (!saved || (await ask("使用已保存凭据?[Y/n]:")).toLowerCase() === "n") {
|
||||
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||||
hidden = true;
|
||||
try { args.password = await rl.question(""); }
|
||||
finally { hidden = false; process.stdout.write("\n"); }
|
||||
}
|
||||
process.stdout.write("正在连接…\n");
|
||||
try { await client.call("connect", args); }
|
||||
finally { args.password = undefined; }
|
||||
} finally { rl.close(); }
|
||||
}
|
||||
|
||||
export async function terminalView(directory: string, view: string, cellAspect = 0.5) {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.TERM === "dumb")
|
||||
throw new Failure("VIEW_REQUIRES_TERMINAL");
|
||||
if (!Number.isFinite(cellAspect) || cellAspect < 0.2 || cellAspect > 2)
|
||||
throw new Failure("INVALID_ARGUMENT");
|
||||
const client = await TerminalClient.attach(directory, view);
|
||||
let raw = false, stopped = false, controls = false, message = "";
|
||||
const previousRaw = process.stdin.isRaw;
|
||||
const stop = () => { stopped = true; };
|
||||
const keypress = (_text: string, key: { name?: string; ctrl?: boolean; meta?: boolean; sequence?: string }) => {
|
||||
if (key.name === "q" || (key.ctrl && key.name === "c")) { stop(); return; }
|
||||
if (controls || key.ctrl || key.meta || !["p", "r", "d"].includes(key.sequence ?? "")) return;
|
||||
controls = true;
|
||||
void client.call("control", { operation: key.sequence === "p" ? "pause" : key.sequence === "r" ? "resume" : "disconnect" })
|
||||
.then(() => { message = ""; if (key.sequence === "d") stopped = true; })
|
||||
.catch((error) => { message = failure(error).code; })
|
||||
.finally(() => { controls = false; });
|
||||
};
|
||||
const write = (value: string) => new Promise<void>((resolve, reject) => {
|
||||
process.stdout.write(value, (error) => error ? reject(error) : resolve());
|
||||
});
|
||||
try {
|
||||
let status = await client.call("status");
|
||||
if (!status.session_id) await connect(client);
|
||||
process.stdin.setRawMode(true);
|
||||
raw = true;
|
||||
emitKeypressEvents(process.stdin);
|
||||
process.stdin.on("keypress", keypress);
|
||||
process.stdin.resume();
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
|
||||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||||
let statusAt = 0, size = "";
|
||||
const colors = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") ? 24
|
||||
: /256color/i.test(process.env.TERM ?? "") ? 256 : 8;
|
||||
while (!stopped) {
|
||||
const started = Date.now();
|
||||
if (started - statusAt >= 500) {
|
||||
status = await client.call("status", {}, 7000);
|
||||
statusAt = Date.now();
|
||||
if (["closed", "failed", "disconnected"].includes(status.state)) break;
|
||||
}
|
||||
// Leave a spare column so terminals with unusual wrap behavior stay safe.
|
||||
const columns = Math.min(160, Math.max(1, (process.stdout.columns || 80) - 1));
|
||||
const rows = Math.min(60, Math.max(1, (process.stdout.rows || 24) - 2));
|
||||
const nextSize = `${columns}:${rows}`;
|
||||
let screen = nextSize !== size ? "\x1b[2J" : "";
|
||||
size = nextSize;
|
||||
try {
|
||||
const frame = await client.call("frame", { columns, rows, colors, cell_aspect: cellAspect }, 7000);
|
||||
if (frame.columns !== columns || frame.rows !== rows || !Array.isArray(frame.lines) || frame.lines.length !== rows)
|
||||
throw new Failure("INVALID_FRAME");
|
||||
// Only renderer-generated SGR sequences and single-width block glyphs.
|
||||
for (const [index, line] of frame.lines.entries()) {
|
||||
if (typeof line !== "string" || !/^(?:\x1b\[[0-9;]*m|[ \u2580-\u259f])*$/u.test(line))
|
||||
throw new Failure("INVALID_FRAME");
|
||||
screen += `\x1b[${index + 1};1H${line}\x1b[0m\x1b[K`;
|
||||
}
|
||||
const cursor = frame.cursor;
|
||||
if (cursor && Number.isInteger(cursor.column) && Number.isInteger(cursor.row) &&
|
||||
cursor.column >= 0 && cursor.column < columns && cursor.row >= 0 && cursor.row < rows)
|
||||
screen += `\x1b[${cursor.row + 1};${cursor.column + 1}H\x1b[97;44m↖\x1b[0m`;
|
||||
} catch (error) {
|
||||
if (!["NOT_READY", "BUSY", "FRAME_TIMEOUT", "STALE_OBSERVATION"].includes(failure(error).code)) throw error;
|
||||
}
|
||||
const label = status.human_paused ? "PAUSED" : status.control ? "AGENT CONTROL" : "OBSERVE";
|
||||
const bar = `${label} | p pause / r resume / d disconnect / q close view${message ? ` | ${message}` : ""}`;
|
||||
// No remote strings or credentials are interpolated into terminal control output.
|
||||
screen += `\x1b[${rows + 1};1H\x1b[0m${bar.replace(/[^\x20-\x7e]/g, "").slice(0, columns)}\x1b[K`;
|
||||
await write(screen);
|
||||
await delay(Math.max(0, 125 - (Date.now() - started)));
|
||||
}
|
||||
} finally {
|
||||
process.stdin.off("keypress", keypress);
|
||||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.off(signal, stop);
|
||||
if (raw) {
|
||||
process.stdin.setRawMode(previousRaw);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l");
|
||||
}
|
||||
await client.call("detach", {}, 1000).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { createServer, createConnection, type Server, type Socket } from "node:net";
|
||||
import { chmod, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { Failure, equal, failure, id, parse, token, type Data } from "./common.js";
|
||||
import { Sessions, type View } from "./sessions.js";
|
||||
import { uiAction } from "./ui-actions.js";
|
||||
|
||||
const frameSchema = z.strictObject({
|
||||
columns: z.int().min(1).max(160),
|
||||
rows: z.int().min(1).max(60),
|
||||
colors: z.union([z.literal(8), z.literal(24), z.literal(256)]),
|
||||
cell_aspect: z.number().finite().min(0.2).max(2),
|
||||
});
|
||||
|
||||
// A private local channel, separate from MCP stdio and browser HTTP. Only the
|
||||
// user's terminal receives rendered frames and connection credentials.
|
||||
export class TerminalPanel {
|
||||
private server?: Server;
|
||||
private starting?: Promise<void>;
|
||||
private endpoint?: string;
|
||||
private descriptor?: string;
|
||||
private closed = false;
|
||||
private sockets = new Set<Socket>();
|
||||
private frames = new Set<string>();
|
||||
private frameTimes = new Map<string, number>();
|
||||
constructor(private sessions: Sessions) {}
|
||||
|
||||
async open(view: View): Promise<Data> {
|
||||
await this.start();
|
||||
for (const id of this.frameTimes.keys())
|
||||
if (!this.sessions.views.has(id)) this.frameTimes.delete(id);
|
||||
return {
|
||||
...this.sessions.describe(view),
|
||||
terminal_command: {
|
||||
command: "crossdesk-mcp",
|
||||
args: ["view", "--view", view.id, "--config-dir", this.sessions.store.directory],
|
||||
},
|
||||
next_step: "Show terminal_command to the user to run in a separate interactive terminal on this host. It displays the existing MCP session and accepts credentials locally. Never run it inside MCP stdio or capture its video into chat. Then wait with crossdesk_get_session(view_id, wait_ms: 25000).",
|
||||
};
|
||||
}
|
||||
|
||||
private async start() {
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
this.starting ??= (async () => {
|
||||
const secret = token(), suffix = token().slice(0, 20);
|
||||
this.endpoint = process.platform === "win32"
|
||||
? `\\\\.\\pipe\\crossdesk-terminal-${suffix}`
|
||||
: join(tmpdir(), `cd-term-${suffix}.sock`);
|
||||
this.server = createServer((socket) => {
|
||||
this.sockets.add(socket);
|
||||
const controller = new AbortController();
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => {
|
||||
this.sockets.delete(socket);
|
||||
controller.abort();
|
||||
});
|
||||
socket.setTimeout(45000, () => socket.destroy());
|
||||
let buffer = Buffer.alloc(0), consumed = false;
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
if (consumed) return socket.destroy();
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (buffer.length > 65536) return socket.destroy();
|
||||
if (!buffer.includes(10)) return;
|
||||
consumed = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const p = JSON.parse(buffer.toString("utf8"));
|
||||
buffer = Buffer.alloc(0);
|
||||
if (typeof p.secret !== "string" || !equal(secret, p.secret))
|
||||
throw new Failure("PANEL_UNAUTHORIZED");
|
||||
const view = this.sessions.view(parse(id, p.view_id));
|
||||
if (view.panel !== "terminal" || !["auto", "terminal"].includes(this.sessions.policy))
|
||||
throw new Failure("PANEL_POLICY_DENIED");
|
||||
view.lastSeen = Date.now();
|
||||
let result: Data;
|
||||
if (p.method === "frame") {
|
||||
const args = parse(frameSchema, p.args);
|
||||
if (this.frames.has(view.id) || Date.now() - (this.frameTimes.get(view.id) ?? 0) < 100)
|
||||
throw new Failure("BUSY", false, true);
|
||||
this.frames.add(view.id);
|
||||
this.frameTimes.set(view.id, Date.now());
|
||||
try { result = await this.sessions.terminalFrame(view, args, controller.signal); }
|
||||
finally { this.frames.delete(view.id); }
|
||||
} else if (p.method === "detach") {
|
||||
view.ready = false;
|
||||
result = this.sessions.describe(view);
|
||||
} else {
|
||||
result = await uiAction(this.sessions, view, p.method, p.args, controller.signal);
|
||||
}
|
||||
socket.end(JSON.stringify({ result }) + "\n");
|
||||
} catch (error) {
|
||||
socket.end(JSON.stringify({ error: failure(error) }) + "\n");
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.server!.once("error", reject);
|
||||
this.server!.listen(this.endpoint!, resolve);
|
||||
});
|
||||
if (process.platform !== "win32") await chmod(this.endpoint, 0o600);
|
||||
this.descriptor = join(this.sessions.store.directory, `terminal-${process.pid}-${suffix}.json`);
|
||||
await writeFile(this.descriptor, JSON.stringify({ endpoint: this.endpoint, secret }), { mode: 0o600, flag: "wx" });
|
||||
if (this.closed) throw new Failure("PANEL_CLOSED");
|
||||
})().catch(async (error) => {
|
||||
await this.cleanup();
|
||||
this.starting = undefined;
|
||||
throw error;
|
||||
});
|
||||
return this.starting;
|
||||
}
|
||||
|
||||
private async cleanup() {
|
||||
for (const socket of this.sockets) socket.destroy();
|
||||
if (this.server?.listening) this.server.close();
|
||||
if (this.descriptor) await unlink(this.descriptor).catch(() => {});
|
||||
if (this.endpoint && process.platform !== "win32") await unlink(this.endpoint).catch(() => {});
|
||||
}
|
||||
async close() {
|
||||
this.closed = true;
|
||||
await this.starting?.catch(() => {});
|
||||
await this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
export class TerminalClient {
|
||||
private constructor(private endpoint: string, private secret: string, private view: string) {}
|
||||
static async attach(directory: string, view: string) {
|
||||
parse(id, view);
|
||||
for (const file of await readdir(directory)) {
|
||||
if (!/^terminal-\d+-[a-zA-Z0-9_-]+\.json$/.test(file)) continue;
|
||||
try {
|
||||
const d = JSON.parse(await readFile(join(directory, file), "utf8"));
|
||||
const client = new TerminalClient(d.endpoint, d.secret, view);
|
||||
await client.call("ready", {}, 2000);
|
||||
return client;
|
||||
} catch { /* An unrelated or exited MCP instance. */ }
|
||||
}
|
||||
throw new Failure("PANEL_NOT_FOUND");
|
||||
}
|
||||
call(method: string, args: Data = {}, timeout = 45000): Promise<Data> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection(this.endpoint);
|
||||
let buffer = Buffer.alloc(0), settled = false;
|
||||
const finish = (error?: Failure, result?: Data) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
if (error) reject(error); else resolve(result!);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Failure("PANEL_TIMEOUT")), timeout);
|
||||
socket.on("error", () => finish(new Failure("PANEL_CLOSED")));
|
||||
socket.once("close", () => finish(new Failure("PANEL_CLOSED")));
|
||||
socket.once("connect", () => socket.write(JSON.stringify({ secret: this.secret, view_id: this.view, method, args }) + "\n"));
|
||||
socket.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
if (buffer.length > 2 * 1024 * 1024) return finish(new Failure("MESSAGE_TOO_LARGE"));
|
||||
if (!buffer.includes(10)) return;
|
||||
try {
|
||||
const reply = JSON.parse(buffer.toString("utf8"));
|
||||
if (reply.error) finish(new Failure(reply.error.code));
|
||||
else if (reply.result && typeof reply.result === "object") finish(undefined, reply.result);
|
||||
else finish(new Failure("INVALID_RESPONSE"));
|
||||
} catch { finish(new Failure("INVALID_RESPONSE")); }
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user