refactor: migrate terminal management to MCP architecture

- Remove legacy terminal management code and integrate a new Terminal MCP server.
- Implement TerminalManager for handling terminal operations within a specific tmux session.
- Introduce tools for creating, listing, renaming, and killing terminals with improved error handling.
- Update references and documentation to reflect the new architecture.
- Ensure compatibility with existing features while enhancing maintainability and scalability.

This refactor streamlines terminal operations and prepares the codebase for future enhancements.
This commit is contained in:
Mohamed Boudra
2025-10-20 14:23:54 +02:00
parent e79a92813b
commit 30fc716b02
10 changed files with 128 additions and 1612 deletions
@@ -1,216 +1,120 @@
import { stepCountIs, streamText, tool, zodSchema } from "ai";
import { stepCountIs, streamText, tool } from "ai";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { z } from "zod";
import {
listTerminals,
createTerminal,
captureTerminal,
sendText,
sendKeys,
renameTerminal,
killTerminal,
} from "../daemon/terminal-manager.js";
import invariant from "tiny-invariant";
import { getPlaywrightTools } from "./playwright-mcp.js";
import { lazySchema } from "@ai-sdk/provider-utils";
import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { createTerminalMcpServer } from "../terminal-mcp/index.js";
/**
* Terminal tools using Vercel AI SDK tool() function
* These are automatically executed when the LLM calls them
* Singleton MCP clients
*/
export const terminalTools = {
list_terminals: tool({
description:
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
inputSchema: zodSchema(z.object({})),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
let terminalMcpClient: Awaited<
ReturnType<typeof experimental_createMCPClient>
> | null = null;
create_terminal: tool({
description:
"Create a new terminal (isolated shell environment) at a specific working directory. Optionally execute an initial command after creation. Terminal names must be unique. Always specify workingDirectory based on context - use project paths when working on projects, or the same directory as current terminal when user says 'another terminal here'. Defaults to ~ only if no context.",
inputSchema: z.object({
name: z
.string()
.describe(
"Unique name for the terminal. Should be descriptive of what the terminal is used for (e.g., 'web-dev', 'api-server', 'tests')."
),
workingDirectory: z
.string()
.describe(
"Absolute path to the working directory for this terminal. Can use ~ for home directory. Required parameter - set contextually based on what the user is working on. Use project paths when working on projects. Defaults to home directory (~) only if no context."
),
initialCommand: z
.string()
.optional()
.describe(
"Optional command to execute after creating the terminal (e.g., 'npm run dev', 'python -m venv venv'). The command runs after changing to the working directory."
),
}),
execute: async ({ name, workingDirectory, initialCommand }) => {
const terminal = await createTerminal({
name,
workingDirectory,
initialCommand,
});
return { terminal };
},
}),
let playwrightMcpClient: Awaited<
ReturnType<typeof experimental_createMCPClient>
> | null = null;
capture_terminal: tool({
description:
"Capture and return the output from a terminal. Returns the last N lines of terminal content. Useful for checking command results, monitoring running processes, or debugging issues.",
inputSchema: z.object({
terminalName: z.string().describe("Name of the terminal"),
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for terminal activity to settle before capturing. Polls every 100ms and waits for 1s of no changes. Useful for commands with delayed output."
),
}),
execute: async ({ terminalName, lines, maxWait }) => {
const output = await captureTerminal(terminalName, lines, maxWait);
return { output };
},
}),
/**
* Get or create Terminal MCP client (singleton)
*/
async function getTerminalMcpClient() {
if (terminalMcpClient) {
return terminalMcpClient;
}
send_text: tool({
description:
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send_keys instead.",
inputSchema: z.object({
terminalName: z.string().describe("Name of the terminal"),
text: z
.string()
.describe(
"Text to type into the terminal. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
),
pressEnter: z
.boolean()
.optional()
.describe(
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
})
.optional()
.describe(
"Capture terminal output after sending text. By default waits for activity to settle."
),
}),
execute: async ({ terminalName, text, pressEnter, return_output }) => {
const output = await sendText(
terminalName,
text,
pressEnter,
return_output
);
return { output };
},
}),
// Create Terminal MCP server
const server = await createTerminalMcpServer({ sessionName: "voice-dev" });
send_keys: tool({
description:
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send_text instead. Supports repeating key presses and optionally capturing output after sending keys.",
inputSchema: z.object({
terminalName: z.string().describe("Name of the terminal"),
keys: z
.string()
.describe(
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
),
repeat: z
.number()
.min(1)
.optional()
.describe("Number of times to repeat the key press (default: 1)"),
return_output: z
.object({
lines: z
.number()
.optional()
.describe("Number of lines to capture (default: 200)"),
waitForSettled: z
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
),
})
.optional()
.describe(
"Capture terminal output after sending keys. By default waits for activity to settle."
),
}),
execute: async ({ terminalName, keys, repeat, return_output }) => {
const output = await sendKeys(terminalName, keys, repeat, return_output);
return { output };
},
}),
// Create linked transport pair
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
rename_terminal: tool({
description:
"Rename a terminal to a more descriptive name. The new name must be unique among all terminals.",
inputSchema: z.object({
terminalName: z.string().describe("Current name of the terminal"),
newName: z
.string()
.describe(
"New unique name for the terminal. Should be descriptive of the terminal's purpose."
),
}),
execute: async ({ terminalName, newName }) => {
await renameTerminal(terminalName, newName);
return { success: true };
},
}),
// Connect server to its transport
await server.connect(serverTransport);
kill_terminal: tool({
description:
"Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.",
inputSchema: z.object({
terminalName: z
.string()
.describe(
"Name of the terminal to kill. Get this from list_terminals."
),
}),
execute: async ({ terminalName }) => {
await killTerminal(terminalName);
return { success: true };
},
}),
// Create client connected to the other side
terminalMcpClient = await experimental_createMCPClient({
transport: clientTransport,
});
console.log("Terminal MCP client initialized");
return terminalMcpClient;
}
/**
* Get or create Playwright MCP client (singleton)
*/
async function getPlaywrightMcpClient() {
if (playwrightMcpClient) {
return playwrightMcpClient;
}
const transport = new StdioClientTransport({
command: "npx",
args: ["@playwright/mcp", "--image-responses", "omit"],
});
playwrightMcpClient = await experimental_createMCPClient({
transport,
});
console.log("Playwright MCP client initialized");
return playwrightMcpClient;
}
/**
* Cache for merged MCP tools
*/
let mcpToolsCache: Record<string, any> | null = null;
let mcpToolsPromise: Promise<Record<string, any>> | null = null;
async function getMcpTools(): Promise<Record<string, any>> {
if (mcpToolsCache) {
return mcpToolsCache;
}
if (mcpToolsPromise) {
return mcpToolsPromise;
}
mcpToolsPromise = (async () => {
const [terminalClient, playwrightClient] = await Promise.all([
getTerminalMcpClient(),
getPlaywrightMcpClient().catch((error) => {
console.error("Failed to initialize Playwright MCP:", error);
return null;
}),
]);
const terminalTools = await terminalClient.tools();
const playwrightTools = playwrightClient
? await playwrightClient.tools()
: {};
const mergedTools = {
...terminalTools,
...playwrightTools,
};
mcpToolsCache = mergedTools;
console.log(`Loaded ${Object.keys(mergedTools).length} MCP tools`);
return mergedTools;
})();
return mcpToolsPromise;
}
/**
* Manual tools that aren't MCP-based
*/
const manualTools = {
present_artifact: tool({
description:
"Present an artifact (plan, diff, screenshot, etc.) to the user for review. Use this when you need to show information that's hard to convey via TTS, such as markdown plans, code diffs, or visual content",
@@ -249,41 +153,6 @@ export const terminalTools = {
}),
};
export const tls = {
list_terminals: tool({
description:
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, currently running command, and the last 5 lines of output for each terminal.",
inputSchema: z.object({}),
execute: async () => {
const terminals = await listTerminals();
return { terminals };
},
}),
};
/**
* Cache Playwright MCP tools (lazy loaded on first use)
*/
let playwrightToolsCache: Record<string, any> | null = null;
let playwrightToolsPromise: Promise<Record<string, any>> | null = null;
async function getCachedPlaywrightTools(): Promise<Record<string, any>> {
if (playwrightToolsCache) {
return playwrightToolsCache;
}
if (playwrightToolsPromise) {
return playwrightToolsPromise;
}
playwrightToolsPromise = getPlaywrightTools().then((tools) => {
playwrightToolsCache = tools;
return tools;
});
return playwrightToolsPromise;
}
/**
* Message interface for conversation
*/
@@ -326,11 +195,11 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
apiKey: process.env.OPENROUTER_API_KEY,
});
// Merge terminal tools with Playwright MCP tools
const pwTools = await getCachedPlaywrightTools();
// Get all MCP tools and merge with manual tools
const mcpTools = await getMcpTools();
const allTools = {
...terminalTools,
...pwTools,
...mcpTools,
...manualTools,
};
const result = await streamText({
@@ -405,3 +274,23 @@ export async function streamLLM(params: StreamLLMParams): Promise<string> {
return fullText;
}
/**
* Cleanup function to close MCP clients
*/
export async function closeMcpClients() {
if (terminalMcpClient) {
await terminalMcpClient.close();
terminalMcpClient = null;
console.log("Terminal MCP client closed");
}
if (playwrightMcpClient) {
await playwrightMcpClient.close();
playwrightMcpClient = null;
console.log("Playwright MCP client closed");
}
mcpToolsCache = null;
mcpToolsPromise = null;
}
@@ -1,55 +0,0 @@
import { experimental_createMCPClient } from "ai";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
let playwrightMCPClient: Awaited<
ReturnType<typeof experimental_createMCPClient>
> | null = null;
/**
* Get or create the Playwright MCP client (singleton)
*/
export async function getPlaywrightMCPClient() {
if (playwrightMCPClient) {
return playwrightMCPClient;
}
const transport = new StdioClientTransport({
command: "npx",
args: ["@playwright/mcp", "--image-responses", "omit"],
});
playwrightMCPClient = await experimental_createMCPClient({
transport,
});
console.log("Playwright MCP client initialized with Firefox");
return playwrightMCPClient;
}
/**
* Get Playwright MCP tools in AI SDK format
*/
export async function getPlaywrightTools() {
try {
const client = await getPlaywrightMCPClient();
const tools = await client.tools();
console.log(`Loaded ${Object.keys(tools).length} Playwright MCP tools`);
return tools;
} catch (error) {
console.error("Failed to initialize Playwright MCP tools:", error);
// Return empty object to allow app to continue without Playwright
return {};
}
}
/**
* Cleanup function to close the MCP client
*/
export async function closePlaywrightMCP() {
if (playwrightMCPClient) {
await playwrightMCPClient.close();
playwrightMCPClient = null;
console.log("Playwright MCP client closed");
}
}