mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
```
feat: add zodSchema wrapper for terminal tools list_terminals ```
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { stepCountIs, streamText, tool } from "ai";
|
||||
import { stepCountIs, streamText, tool, zodSchema } from "ai";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "../daemon/terminal-manager.js";
|
||||
import invariant from "tiny-invariant";
|
||||
import { getPlaywrightTools } from "./playwright-mcp.js";
|
||||
import { lazySchema } from "@ai-sdk/provider-utils";
|
||||
|
||||
/**
|
||||
* Terminal tools using Vercel AI SDK tool() function
|
||||
@@ -21,7 +22,7 @@ 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: z.object({}),
|
||||
inputSchema: zodSchema(z.object({})),
|
||||
execute: async () => {
|
||||
const terminals = await listTerminals();
|
||||
return { terminals };
|
||||
@@ -248,6 +249,18 @@ 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)
|
||||
*/
|
||||
|
||||
129
packages/voice-assistant/src/terminal-mcp/REFERENCES.md
Normal file
129
packages/voice-assistant/src/terminal-mcp/REFERENCES.md
Normal file
@@ -0,0 +1,129 @@
|
||||
```typescript
|
||||
const server = new McpServer({
|
||||
name: "my-app",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Simple tool with parameters
|
||||
server.registerTool(
|
||||
"calculate-bmi",
|
||||
{
|
||||
title: "BMI Calculator",
|
||||
description: "Calculate Body Mass Index",
|
||||
inputSchema: {
|
||||
weightKg: z.number(),
|
||||
heightM: z.number(),
|
||||
},
|
||||
outputSchema: { bmi: z.number() },
|
||||
},
|
||||
async ({ weightKg, heightM }) => {
|
||||
const output = { bmi: weightKg / (heightM * heightM) };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(output),
|
||||
},
|
||||
],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Async tool with external API call
|
||||
server.registerTool(
|
||||
"fetch-weather",
|
||||
{
|
||||
title: "Weather Fetcher",
|
||||
description: "Get weather data for a city",
|
||||
inputSchema: { city: z.string() },
|
||||
outputSchema: { temperature: z.number(), conditions: z.string() },
|
||||
},
|
||||
async ({ city }) => {
|
||||
const response = await fetch(`https://api.weather.com/${city}`);
|
||||
const data = await response.json();
|
||||
const output = { temperature: data.temp, conditions: data.conditions };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool that returns ResourceLinks
|
||||
server.registerTool(
|
||||
"list-files",
|
||||
{
|
||||
title: "List Files",
|
||||
description: "List project files",
|
||||
inputSchema: { pattern: z.string() },
|
||||
outputSchema: {
|
||||
count: z.number(),
|
||||
files: z.array(z.object({ name: z.string(), uri: z.string() })),
|
||||
},
|
||||
},
|
||||
async ({ pattern }) => {
|
||||
const output = {
|
||||
count: 2,
|
||||
files: [
|
||||
{ name: "README.md", uri: "file:///project/README.md" },
|
||||
{ name: "index.ts", uri: "file:///project/src/index.ts" },
|
||||
],
|
||||
};
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: JSON.stringify(output) },
|
||||
// ResourceLinks let tools return references without file content
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/README.md",
|
||||
name: "README.md",
|
||||
mimeType: "text/markdown",
|
||||
description: "A README file",
|
||||
},
|
||||
{
|
||||
type: "resource_link",
|
||||
uri: "file:///project/src/index.ts",
|
||||
name: "index.ts",
|
||||
mimeType: "text/typescript",
|
||||
description: "An index file",
|
||||
},
|
||||
],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
|
||||
bench('InMemoryTransport', async () => {
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const server = createServer();
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([
|
||||
client.connect(clientTransport),
|
||||
server.connect(serverTransport),
|
||||
]);
|
||||
|
||||
const tools = await client.listTools();
|
||||
if (!tools.tools.length) throw new Error('No tools found');
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
7
packages/voice-assistant/src/terminal-mcp/index.ts
Normal file
7
packages/voice-assistant/src/terminal-mcp/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js";
|
||||
export { TerminalManager } from "./terminal-manager.js";
|
||||
export type {
|
||||
TerminalInfo,
|
||||
CreateTerminalParams,
|
||||
CreateTerminalResult,
|
||||
} from "./terminal-manager.js";
|
||||
325
packages/voice-assistant/src/terminal-mcp/server.ts
Normal file
325
packages/voice-assistant/src/terminal-mcp/server.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { TerminalManager } from "./terminal-manager.js";
|
||||
|
||||
export interface TerminalMcpServerOptions {
|
||||
sessionName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure the Terminal MCP Server
|
||||
* Multiple instances can run independently with different session names
|
||||
*/
|
||||
export async function createTerminalMcpServer(
|
||||
options: TerminalMcpServerOptions
|
||||
): Promise<McpServer> {
|
||||
const { sessionName } = options;
|
||||
const terminalManager = new TerminalManager(sessionName);
|
||||
|
||||
// Initialize the session
|
||||
await terminalManager.initialize();
|
||||
|
||||
const server = new McpServer({
|
||||
name: "terminal-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Tool: list_terminals
|
||||
server.registerTool(
|
||||
"list_terminals",
|
||||
{
|
||||
title: "List Terminals",
|
||||
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: {},
|
||||
outputSchema: {
|
||||
terminals: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
currentCommand: z.string(),
|
||||
lastLines: z.string().optional(),
|
||||
})
|
||||
),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const terminals = await terminalManager.listTerminals();
|
||||
const output = { terminals };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: create_terminal
|
||||
server.registerTool(
|
||||
"create_terminal",
|
||||
{
|
||||
title: "Create Terminal",
|
||||
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: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
terminal: z.object({
|
||||
name: z.string(),
|
||||
workingDirectory: z.string(),
|
||||
currentCommand: z.string(),
|
||||
commandOutput: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ name, workingDirectory, initialCommand }) => {
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
name,
|
||||
workingDirectory,
|
||||
initialCommand,
|
||||
});
|
||||
const output = { terminal };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: capture_terminal
|
||||
server.registerTool(
|
||||
"capture_terminal",
|
||||
{
|
||||
title: "Capture Terminal",
|
||||
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: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ terminalName, lines, maxWait }) => {
|
||||
const output = await terminalManager.captureTerminal(
|
||||
terminalName,
|
||||
lines,
|
||||
maxWait
|
||||
);
|
||||
const result = { output };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: send_text
|
||||
server.registerTool(
|
||||
"send_text",
|
||||
{
|
||||
title: "Send Text",
|
||||
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: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ terminalName, text, pressEnter, return_output }) => {
|
||||
const output = await terminalManager.sendText(
|
||||
terminalName,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output
|
||||
);
|
||||
const result = { output: output || undefined };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: send_keys
|
||||
server.registerTool(
|
||||
"send_keys",
|
||||
{
|
||||
title: "Send Keys",
|
||||
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: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
output: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ terminalName, keys, repeat, return_output }) => {
|
||||
const output = await terminalManager.sendKeys(
|
||||
terminalName,
|
||||
keys,
|
||||
repeat,
|
||||
return_output
|
||||
);
|
||||
const result = { output: output || undefined };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
structuredContent: result,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: rename_terminal
|
||||
server.registerTool(
|
||||
"rename_terminal",
|
||||
{
|
||||
title: "Rename Terminal",
|
||||
description:
|
||||
"Rename a terminal to a more descriptive name. The new name must be unique among all terminals.",
|
||||
inputSchema: {
|
||||
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."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ terminalName, newName }) => {
|
||||
await terminalManager.renameTerminal(terminalName, newName);
|
||||
const output = { success: true };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool: kill_terminal
|
||||
server.registerTool(
|
||||
"kill_terminal",
|
||||
{
|
||||
title: "Kill Terminal",
|
||||
description:
|
||||
"Close and destroy a terminal. This will terminate any running processes in the terminal. Use with caution.",
|
||||
inputSchema: {
|
||||
terminalName: z
|
||||
.string()
|
||||
.describe(
|
||||
"Name of the terminal to kill. Get this from list_terminals."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ terminalName }) => {
|
||||
await terminalManager.killTerminal(terminalName);
|
||||
const output = { success: true };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(output) }],
|
||||
structuredContent: output,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
300
packages/voice-assistant/src/terminal-mcp/terminal-manager.ts
Normal file
300
packages/voice-assistant/src/terminal-mcp/terminal-manager.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
findSessionByName,
|
||||
createSession,
|
||||
listWindows,
|
||||
createWindow,
|
||||
listPanes,
|
||||
findWindowByName,
|
||||
capturePaneContent,
|
||||
sendText as tmuxSendText,
|
||||
sendKeys as tmuxSendKeys,
|
||||
renameWindow,
|
||||
killWindow,
|
||||
isWindowNameUnique,
|
||||
getCurrentWorkingDirectory,
|
||||
getCurrentCommand,
|
||||
waitForPaneActivityToSettle,
|
||||
} from "./tmux.js";
|
||||
|
||||
// Terminal model: session → windows (single pane per window)
|
||||
// Terminals are identified by their unique names, not IDs
|
||||
|
||||
export interface TerminalInfo {
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
currentCommand: string;
|
||||
lastLines?: string;
|
||||
}
|
||||
|
||||
export interface CreateTerminalParams {
|
||||
name: string;
|
||||
workingDirectory: string;
|
||||
initialCommand?: string;
|
||||
}
|
||||
|
||||
export interface CreateTerminalResult extends TerminalInfo {
|
||||
commandOutput?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal manager for a specific tmux session
|
||||
* Multiple instances can coexist with different session names
|
||||
*/
|
||||
export class TerminalManager {
|
||||
constructor(private sessionName: string) {}
|
||||
|
||||
/**
|
||||
* Initialize the tmux session
|
||||
* Creates it if it doesn't exist
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
await createSession(this.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all terminals in the session
|
||||
* Returns terminal info including name, active status, working directory, and current command
|
||||
*/
|
||||
async listTerminals(): Promise<TerminalInfo[]> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
`Session '${this.sessionName}' not found. Call initialize() first.`
|
||||
);
|
||||
}
|
||||
|
||||
const windows = await listWindows(session.id);
|
||||
|
||||
const terminals: TerminalInfo[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
// Get the first (and only) pane in this window
|
||||
const paneId = `${window.id}.0`;
|
||||
|
||||
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||
const currentCommand = await getCurrentCommand(paneId);
|
||||
const lastLines = await capturePaneContent(paneId, 5, false);
|
||||
|
||||
terminals.push({
|
||||
name: window.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
lastLines,
|
||||
});
|
||||
}
|
||||
|
||||
return terminals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new terminal (tmux window) with specified name and working directory
|
||||
* Optionally execute an initial command
|
||||
*/
|
||||
async createTerminal(
|
||||
params: CreateTerminalParams
|
||||
): Promise<CreateTerminalResult> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(
|
||||
`Session '${this.sessionName}' not found. Call initialize() first.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate name uniqueness
|
||||
const isUnique = await isWindowNameUnique(session.id, params.name);
|
||||
if (!isUnique) {
|
||||
throw new Error(
|
||||
`Terminal with name '${params.name}' already exists. Please choose a unique name.`
|
||||
);
|
||||
}
|
||||
|
||||
// Create the window
|
||||
const windowResult = await createWindow(session.id, params.name, {
|
||||
workingDirectory: params.workingDirectory,
|
||||
command: params.initialCommand,
|
||||
});
|
||||
|
||||
if (!windowResult) {
|
||||
throw new Error(`Failed to create terminal '${params.name}'`);
|
||||
}
|
||||
|
||||
const paneId = windowResult.paneId;
|
||||
|
||||
// Get terminal info
|
||||
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||
const currentCommand = await getCurrentCommand(paneId);
|
||||
|
||||
return {
|
||||
name: windowResult.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
commandOutput: windowResult.output,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture output from a terminal by name
|
||||
* Returns the last N lines of terminal content
|
||||
* If maxWait is provided, waits for terminal activity to settle before capturing
|
||||
*/
|
||||
async captureTerminal(
|
||||
terminalName: string,
|
||||
lines: number = 200,
|
||||
maxWait?: number
|
||||
): Promise<string> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
// Wait for activity to settle if maxWait is provided
|
||||
if (maxWait) {
|
||||
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
|
||||
}
|
||||
|
||||
return capturePaneContent(pane.id, lines, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text to a terminal by name, optionally press Enter, optionally return output
|
||||
*/
|
||||
async sendText(
|
||||
terminalName: string,
|
||||
text: string,
|
||||
pressEnter: boolean = false,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
|
||||
): Promise<string | void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendText({
|
||||
paneId: pane.id,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send special keys or key combinations to a terminal by name
|
||||
* Useful for TUI navigation, control sequences, and interactive applications
|
||||
*/
|
||||
async sendKeys(
|
||||
terminalName: string,
|
||||
keys: string,
|
||||
repeat: number = 1,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
|
||||
): Promise<string | void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendKeys({
|
||||
paneId: pane.id,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a terminal by name
|
||||
* Validates that the new name is unique
|
||||
*/
|
||||
async renameTerminal(
|
||||
terminalName: string,
|
||||
newName: string
|
||||
): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// renameWindow handles uniqueness validation and name resolution internally
|
||||
await renameWindow(session.id, terminalName, newName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill (close/destroy) a terminal by name
|
||||
*/
|
||||
async killTerminal(terminalName: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
|
||||
);
|
||||
}
|
||||
|
||||
await killWindow(window.id);
|
||||
}
|
||||
}
|
||||
923
packages/voice-assistant/src/terminal-mcp/tmux.ts
Normal file
923
packages/voice-assistant/src/terminal-mcp/tmux.ts
Normal file
@@ -0,0 +1,923 @@
|
||||
import { exec as execCallback } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import os from "node:os";
|
||||
|
||||
const exec = promisify(execCallback);
|
||||
|
||||
// Basic interfaces for tmux objects
|
||||
export interface TmuxSession {
|
||||
id: string;
|
||||
name: string;
|
||||
attached: boolean;
|
||||
windows: number;
|
||||
}
|
||||
|
||||
export interface TmuxWindow {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface TmuxPane {
|
||||
id: string;
|
||||
windowId: string;
|
||||
active: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface CommandExecution {
|
||||
id: string;
|
||||
paneId: string;
|
||||
command: string;
|
||||
status: "pending" | "completed" | "error";
|
||||
startTime: Date;
|
||||
result?: string;
|
||||
exitCode?: number;
|
||||
rawMode?: boolean;
|
||||
}
|
||||
|
||||
export type ShellType = "bash" | "zsh" | "fish";
|
||||
|
||||
let shellConfig: { type: ShellType } = { type: "bash" };
|
||||
|
||||
export function setShellConfig(config: { type: string }): void {
|
||||
// Validate shell type
|
||||
const validShells: ShellType[] = ["bash", "zsh", "fish"];
|
||||
|
||||
if (validShells.includes(config.type as ShellType)) {
|
||||
shellConfig = { type: config.type as ShellType };
|
||||
} else {
|
||||
shellConfig = { type: "bash" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tmux command and return the result
|
||||
*/
|
||||
export async function executeTmux(tmuxCommand: string): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await exec(`tmux ${tmuxCommand}`);
|
||||
return stdout.trim();
|
||||
} catch (error: any) {
|
||||
throw new Error(`Failed to execute tmux command: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tmux server is running
|
||||
*/
|
||||
export async function isTmuxRunning(): Promise<boolean> {
|
||||
try {
|
||||
await executeTmux("list-sessions -F '#{session_name}'");
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tmux sessions
|
||||
*/
|
||||
export async function listSessions(): Promise<TmuxSession[]> {
|
||||
const format =
|
||||
"#{session_id}:#{session_name}:#{?session_attached,1,0}:#{session_windows}";
|
||||
const output = await executeTmux(`list-sessions -F '${format}'`);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, name, attached, windows] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
attached: attached === "1",
|
||||
windows: parseInt(windows, 10),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a session by name
|
||||
*/
|
||||
export async function findSessionByName(
|
||||
name: string
|
||||
): Promise<TmuxSession | null> {
|
||||
try {
|
||||
const sessions = await listSessions();
|
||||
return sessions.find((session) => session.name === name) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a window by name in a session
|
||||
*/
|
||||
export async function findWindowByName(
|
||||
sessionId: string,
|
||||
name: string
|
||||
): Promise<TmuxWindow | null> {
|
||||
try {
|
||||
const windows = await listWindows(sessionId);
|
||||
return windows.find((window) => window.name === name) || null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a window name is unique in a session
|
||||
*/
|
||||
export async function isWindowNameUnique(
|
||||
sessionId: string,
|
||||
name: string
|
||||
): Promise<boolean> {
|
||||
const window = await findWindowByName(sessionId, name);
|
||||
return window === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List windows in a session
|
||||
*/
|
||||
export async function listWindows(sessionId: string): Promise<TmuxWindow[]> {
|
||||
const format = "#{window_id}:#{window_name}:#{?window_active,1,0}";
|
||||
const output = await executeTmux(
|
||||
`list-windows -t '${sessionId}' -F '${format}'`
|
||||
);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, name, active] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
active: active === "1",
|
||||
sessionId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List panes in a window
|
||||
*/
|
||||
export async function listPanes(windowId: string): Promise<TmuxPane[]> {
|
||||
const format = "#{pane_id}:#{pane_title}:#{?pane_active,1,0}";
|
||||
const output = await executeTmux(
|
||||
`list-panes -t '${windowId}' -F '${format}'`
|
||||
);
|
||||
|
||||
if (!output) return [];
|
||||
|
||||
return output.split("\n").map((line) => {
|
||||
const [id, title, active] = line.split(":");
|
||||
return {
|
||||
id,
|
||||
windowId,
|
||||
title: title,
|
||||
active: active === "1",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture content from a specific pane, by default the latest 200 lines.
|
||||
*/
|
||||
export async function capturePaneContent(
|
||||
paneId: string,
|
||||
lines: number = 200,
|
||||
includeColors: boolean = false
|
||||
): Promise<string> {
|
||||
const colorFlag = includeColors ? "-e" : "";
|
||||
// Capture a large range to ensure we have enough content
|
||||
const captureLines = Math.max(lines, 1000);
|
||||
const output = await executeTmux(
|
||||
`capture-pane -p ${colorFlag} -t '${paneId}' -S -${captureLines} -E -`
|
||||
);
|
||||
|
||||
// Trim trailing whitespace, split by lines, take last N lines, rejoin
|
||||
const trimmed = output.trimEnd();
|
||||
const allLines = trimmed.split("\n");
|
||||
const lastLines = allLines.slice(-lines);
|
||||
|
||||
return lastLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current working directory of a pane
|
||||
*/
|
||||
export async function getCurrentWorkingDirectory(
|
||||
paneId: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const tmuxPath = await executeTmux(
|
||||
`display-message -p -t '${paneId}' '#{pane_current_path}'`
|
||||
);
|
||||
|
||||
// If tmux returns a valid path, use it
|
||||
if (tmuxPath && tmuxPath.trim()) {
|
||||
return tmuxPath;
|
||||
}
|
||||
|
||||
// Fallback: get the PID and use lsof to find the actual CWD
|
||||
const shellPid = await executeTmux(
|
||||
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
||||
);
|
||||
const { stdout } = await exec(
|
||||
`lsof -a -p ${shellPid.trim()} -d cwd -Fn | grep '^n' | cut -c2-`
|
||||
);
|
||||
return stdout.trim() || tmuxPath;
|
||||
} catch (error) {
|
||||
// If all else fails, return empty string
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current command running in a pane (full command line with arguments)
|
||||
* Gets the immediate child process of the shell, not the shell itself
|
||||
*/
|
||||
export async function getCurrentCommand(paneId: string): Promise<string> {
|
||||
try {
|
||||
// Get the shell PID (the pane's main process)
|
||||
const shellPid = await executeTmux(
|
||||
`display-message -p -t '${paneId}' '#{pane_pid}'`
|
||||
);
|
||||
|
||||
// First, check if there's a child process using comm= (works for all programs including top)
|
||||
// Use 'ax' flags to see all processes
|
||||
const { stdout: childPid } = await exec(
|
||||
`ps ax -o pid=,ppid=,comm= | awk '$2 == ${shellPid.trim()} { print $1; exit }'`
|
||||
);
|
||||
|
||||
if (childPid.trim()) {
|
||||
// Found a child process, get its full command with args
|
||||
const { stdout: fullCmd } = await exec(
|
||||
`ps -p ${childPid.trim()} -o args= | sed 's/\\\\012.*//'`
|
||||
);
|
||||
const command = fullCmd.trim();
|
||||
if (command) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
// No child process, just return the shell name
|
||||
const { stdout: shellCmd } = await exec(`ps -p ${shellPid} -o comm=`);
|
||||
return shellCmd.trim();
|
||||
} catch (error) {
|
||||
// Fallback to just the command name if ps fails
|
||||
return executeTmux(
|
||||
`display-message -p -t '${paneId}' '#{pane_current_command}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tmux session with a default window named "default" in home directory
|
||||
*/
|
||||
export async function createSession(name: string): Promise<TmuxSession | null> {
|
||||
const homeDir = process.env.HOME || "~";
|
||||
await executeTmux(`new-session -d -s "${name}" -n "default" -c "${homeDir}"`);
|
||||
|
||||
// Disable automatic window renaming for all windows in the session
|
||||
await executeTmux(`set-window-option -t "${name}" automatic-rename off`);
|
||||
|
||||
return findSessionByName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand tilde in path to home directory
|
||||
*/
|
||||
function expandTilde(path: string): string {
|
||||
if (path.startsWith("~/")) {
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
return path.replace("~", homeDir);
|
||||
}
|
||||
if (path === "~") {
|
||||
return process.env.HOME || os.homedir();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new window in a session with optional working directory and initial command
|
||||
*/
|
||||
export async function createWindow(
|
||||
sessionId: string,
|
||||
name: string,
|
||||
options?: {
|
||||
workingDirectory?: string;
|
||||
command?: string;
|
||||
}
|
||||
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
|
||||
// Validate name uniqueness
|
||||
const isUnique = await isWindowNameUnique(sessionId, name);
|
||||
if (!isUnique) {
|
||||
throw new Error(
|
||||
`Terminal with name '${name}' already exists. Please choose a unique name.`
|
||||
);
|
||||
}
|
||||
|
||||
// Build new-window command with optional working directory
|
||||
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
|
||||
if (options?.workingDirectory) {
|
||||
// Expand tilde to home directory before passing to tmux
|
||||
const expandedPath = expandTilde(options.workingDirectory);
|
||||
newWindowCmd += ` -c '${expandedPath}'`;
|
||||
}
|
||||
|
||||
await executeTmux(newWindowCmd);
|
||||
const windows = await listWindows(sessionId);
|
||||
const window = windows.find((window) => window.name === name);
|
||||
|
||||
if (!window) return null;
|
||||
|
||||
// Disable automatic window renaming
|
||||
await executeTmux(`set-window-option -t '${window.id}' automatic-rename off`);
|
||||
|
||||
// Get the default pane created with the window
|
||||
const panes = await listPanes(window.id);
|
||||
const defaultPane = panes[0];
|
||||
|
||||
let commandOutput: string | undefined;
|
||||
|
||||
// If command is provided, execute it in the new pane
|
||||
if (options?.command && defaultPane) {
|
||||
commandOutput = (await sendText({
|
||||
paneId: defaultPane.id,
|
||||
text: options.command,
|
||||
pressEnter: true,
|
||||
return_output: {
|
||||
waitForSettled: true,
|
||||
maxWait: 120000,
|
||||
},
|
||||
})) as string;
|
||||
}
|
||||
|
||||
return {
|
||||
...window,
|
||||
paneId: defaultPane?.id || "",
|
||||
output: commandOutput,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux session by ID
|
||||
*/
|
||||
export async function killSession(sessionId: string): Promise<void> {
|
||||
await executeTmux(`kill-session -t '${sessionId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux window by ID
|
||||
*/
|
||||
export async function killWindow(windowId: string): Promise<void> {
|
||||
await executeTmux(`kill-window -t '${windowId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a tmux pane by ID
|
||||
*/
|
||||
export async function killPane(paneId: string): Promise<void> {
|
||||
await executeTmux(`kill-pane -t '${paneId}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a tmux window by name or ID
|
||||
*/
|
||||
export async function renameWindow(
|
||||
sessionId: string,
|
||||
windowNameOrId: string,
|
||||
newName: string
|
||||
): Promise<void> {
|
||||
// Validate new name is unique
|
||||
const isUnique = await isWindowNameUnique(sessionId, newName);
|
||||
if (!isUnique) {
|
||||
throw new Error(
|
||||
`Terminal with name '${newName}' already exists. Please choose a unique name.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if windowNameOrId is a window ID (starts with @) or a name
|
||||
let windowId: string;
|
||||
if (windowNameOrId.startsWith("@")) {
|
||||
windowId = windowNameOrId;
|
||||
} else {
|
||||
// Resolve name to ID
|
||||
const window = await findWindowByName(sessionId, windowNameOrId);
|
||||
if (!window) {
|
||||
throw new Error(`Terminal '${windowNameOrId}' not found.`);
|
||||
}
|
||||
windowId = window.id;
|
||||
}
|
||||
|
||||
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
|
||||
// Disable automatic renaming to preserve the manual name
|
||||
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a tmux pane horizontally or vertically
|
||||
*/
|
||||
export async function splitPane(
|
||||
targetPaneId: string,
|
||||
direction: "horizontal" | "vertical" = "vertical",
|
||||
size?: number
|
||||
): Promise<TmuxPane | null> {
|
||||
// Build the split-window command
|
||||
let splitCommand = "split-window";
|
||||
|
||||
// Add direction flag (-h for horizontal, -v for vertical)
|
||||
if (direction === "horizontal") {
|
||||
splitCommand += " -h";
|
||||
} else {
|
||||
splitCommand += " -v";
|
||||
}
|
||||
|
||||
// Add target pane
|
||||
splitCommand += ` -t '${targetPaneId}'`;
|
||||
|
||||
// Add size if specified (as percentage)
|
||||
if (size !== undefined && size > 0 && size < 100) {
|
||||
splitCommand += ` -p ${size}`;
|
||||
}
|
||||
|
||||
// Execute the split command
|
||||
await executeTmux(splitCommand);
|
||||
|
||||
// Get the window ID from the target pane to list all panes
|
||||
const windowInfo = await executeTmux(
|
||||
`display-message -p -t '${targetPaneId}' '#{window_id}'`
|
||||
);
|
||||
|
||||
// List all panes in the window to find the newly created one
|
||||
const panes = await listPanes(windowInfo);
|
||||
|
||||
// The newest pane is typically the last one in the list
|
||||
return panes.length > 0 ? panes[panes.length - 1] : null;
|
||||
}
|
||||
|
||||
// Map to track ongoing command executions
|
||||
const activeCommands = new Map<string, CommandExecution>();
|
||||
|
||||
const startMarkerText = "TMUX_MCP_START";
|
||||
const endMarkerPrefix = "TMUX_MCP_DONE_";
|
||||
|
||||
// Execute a command in a tmux pane and track its execution
|
||||
export async function executeCommand(
|
||||
paneId: string,
|
||||
command: string,
|
||||
rawMode?: boolean,
|
||||
noEnter?: boolean
|
||||
): Promise<string> {
|
||||
// Generate unique ID for this command execution
|
||||
const commandId = uuidv4();
|
||||
|
||||
let fullCommand: string;
|
||||
if (rawMode || noEnter) {
|
||||
fullCommand = command;
|
||||
} else {
|
||||
const endMarkerText = getEndMarkerText();
|
||||
fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||
}
|
||||
|
||||
// Store command in tracking map
|
||||
activeCommands.set(commandId, {
|
||||
id: commandId,
|
||||
paneId,
|
||||
command,
|
||||
status: "pending",
|
||||
startTime: new Date(),
|
||||
rawMode: rawMode || noEnter,
|
||||
});
|
||||
|
||||
// Send the command to the tmux pane
|
||||
if (noEnter) {
|
||||
// Check if this is a special key or key combination
|
||||
// Special keys in tmux are typically capitalized or have special names
|
||||
const specialKeys = [
|
||||
"Up",
|
||||
"Down",
|
||||
"Left",
|
||||
"Right",
|
||||
"Escape",
|
||||
"Tab",
|
||||
"Enter",
|
||||
"Space",
|
||||
"BSpace",
|
||||
"Delete",
|
||||
"Home",
|
||||
"End",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"F1",
|
||||
"F2",
|
||||
"F3",
|
||||
"F4",
|
||||
"F5",
|
||||
"F6",
|
||||
"F7",
|
||||
"F8",
|
||||
"F9",
|
||||
"F10",
|
||||
"F11",
|
||||
"F12",
|
||||
"BTab",
|
||||
];
|
||||
|
||||
// Split the command into parts to handle combinations like "C-b" or "M-x"
|
||||
const parts = fullCommand.split("-");
|
||||
const isSpecialKey = parts.length === 1 && specialKeys.includes(parts[0]);
|
||||
const isKeyCombo =
|
||||
parts.length > 1 &&
|
||||
(parts[0] === "C" || // Control
|
||||
parts[0] === "M" || // Meta/Alt
|
||||
parts[0] === "S"); // Shift
|
||||
|
||||
if (isSpecialKey || isKeyCombo) {
|
||||
// Send special key or key combination as-is
|
||||
await executeTmux(`send-keys -t '${paneId}' ${fullCommand}`);
|
||||
} else {
|
||||
// For regular text, send each character individually to ensure proper processing
|
||||
// This handles both single characters (like 'q', 'f') and strings (like 'beam')
|
||||
for (const char of fullCommand) {
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||
);
|
||||
}
|
||||
|
||||
return commandId;
|
||||
}
|
||||
|
||||
export async function checkCommandStatus(
|
||||
commandId: string
|
||||
): Promise<CommandExecution | null> {
|
||||
const command = activeCommands.get(commandId);
|
||||
if (!command) return null;
|
||||
|
||||
if (command.status !== "pending") return command;
|
||||
|
||||
const content = await capturePaneContent(command.paneId, 1000);
|
||||
|
||||
if (command.rawMode) {
|
||||
command.result =
|
||||
"Status tracking unavailable for rawMode commands. Use capture-pane to monitor interactive apps instead.";
|
||||
return command;
|
||||
}
|
||||
|
||||
// Find the last occurrence of the markers
|
||||
const startIndex = content.lastIndexOf(startMarkerText);
|
||||
const endIndex = content.lastIndexOf(endMarkerPrefix);
|
||||
|
||||
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
|
||||
command.result = "Command output could not be captured properly";
|
||||
return command;
|
||||
}
|
||||
|
||||
// Extract exit code from the end marker line
|
||||
const endLine = content.substring(endIndex).split("\n")[0];
|
||||
const endMarkerRegex = new RegExp(`${endMarkerPrefix}(\\d+)`);
|
||||
const exitCodeMatch = endLine.match(endMarkerRegex);
|
||||
|
||||
if (exitCodeMatch) {
|
||||
const exitCode = parseInt(exitCodeMatch[1], 10);
|
||||
|
||||
command.status = exitCode === 0 ? "completed" : "error";
|
||||
command.exitCode = exitCode;
|
||||
|
||||
// Extract output between the start and end markers
|
||||
const outputStart = startIndex + startMarkerText.length;
|
||||
const outputContent = content.substring(outputStart, endIndex).trim();
|
||||
|
||||
command.result = outputContent
|
||||
.substring(outputContent.indexOf("\n") + 1)
|
||||
.trim();
|
||||
|
||||
// Update in map
|
||||
activeCommands.set(commandId, command);
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
// Get command by ID
|
||||
export function getCommand(commandId: string): CommandExecution | null {
|
||||
return activeCommands.get(commandId) || null;
|
||||
}
|
||||
|
||||
// Get all active command IDs
|
||||
export function getActiveCommandIds(): string[] {
|
||||
return Array.from(activeCommands.keys());
|
||||
}
|
||||
|
||||
// Clean up completed commands older than a certain time
|
||||
export function cleanupOldCommands(maxAgeMinutes: number = 60): void {
|
||||
const now = new Date();
|
||||
|
||||
for (const [id, command] of activeCommands.entries()) {
|
||||
const ageMinutes =
|
||||
(now.getTime() - command.startTime.getTime()) / (1000 * 60);
|
||||
|
||||
if (command.status !== "pending" && ageMinutes > maxAgeMinutes) {
|
||||
activeCommands.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getEndMarkerText(): string {
|
||||
return shellConfig.type === "fish"
|
||||
? `${endMarkerPrefix}$status`
|
||||
: `${endMarkerPrefix}$?`;
|
||||
}
|
||||
|
||||
// New consolidated API functions
|
||||
|
||||
export type ListScope = "all" | "sessions" | "session" | "window" | "pane";
|
||||
|
||||
interface SessionWithWindows extends TmuxSession {
|
||||
windowDetails?: WindowWithPanes[];
|
||||
}
|
||||
|
||||
interface WindowWithPanes extends TmuxWindow {
|
||||
paneDetails?: TmuxPane[];
|
||||
}
|
||||
|
||||
export async function list({
|
||||
scope,
|
||||
target,
|
||||
}: {
|
||||
scope: ListScope;
|
||||
target?: string;
|
||||
}): Promise<
|
||||
| SessionWithWindows[]
|
||||
| TmuxSession[]
|
||||
| TmuxWindow[]
|
||||
| TmuxPane[]
|
||||
| TmuxSession
|
||||
| TmuxWindow
|
||||
| TmuxPane
|
||||
> {
|
||||
if (scope === "all") {
|
||||
const sessions = await listSessions();
|
||||
const sessionsWithDetails: SessionWithWindows[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const windows = await listWindows(session.id);
|
||||
const windowsWithPanes: WindowWithPanes[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const panes = await listPanes(window.id);
|
||||
windowsWithPanes.push({
|
||||
...window,
|
||||
paneDetails: panes,
|
||||
});
|
||||
}
|
||||
|
||||
sessionsWithDetails.push({
|
||||
...session,
|
||||
windowDetails: windowsWithPanes,
|
||||
});
|
||||
}
|
||||
|
||||
return sessionsWithDetails;
|
||||
}
|
||||
|
||||
if (scope === "sessions") {
|
||||
return listSessions();
|
||||
}
|
||||
|
||||
if (scope === "session") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'session'");
|
||||
}
|
||||
return listWindows(target);
|
||||
}
|
||||
|
||||
if (scope === "window") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'window'");
|
||||
}
|
||||
return listPanes(target);
|
||||
}
|
||||
|
||||
if (scope === "pane") {
|
||||
if (!target) {
|
||||
throw new Error("target is required for scope 'pane'");
|
||||
}
|
||||
const windowId = await executeTmux(
|
||||
`display-message -p -t '${target}' '#{window_id}'`
|
||||
);
|
||||
const panes = await listPanes(windowId);
|
||||
const pane = panes.find((p) => p.id === target);
|
||||
if (!pane) {
|
||||
throw new Error(`Pane not found: ${target}`);
|
||||
}
|
||||
return pane;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
|
||||
export type KillScope = "session" | "window" | "pane";
|
||||
|
||||
export async function kill({
|
||||
scope,
|
||||
target,
|
||||
}: {
|
||||
scope: KillScope;
|
||||
target: string;
|
||||
}): Promise<void> {
|
||||
if (scope === "session") {
|
||||
return killSession(target);
|
||||
}
|
||||
|
||||
if (scope === "window") {
|
||||
return killWindow(target);
|
||||
}
|
||||
|
||||
if (scope === "pane") {
|
||||
return killPane(target);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid scope: ${scope}`);
|
||||
}
|
||||
|
||||
export interface ShellCommandResult {
|
||||
command: string;
|
||||
status: "completed" | "error";
|
||||
exitCode: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export async function executeShellCommand({
|
||||
paneId,
|
||||
command,
|
||||
timeout = 30000,
|
||||
}: {
|
||||
paneId: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}): Promise<ShellCommandResult> {
|
||||
const commandId = uuidv4();
|
||||
const endMarkerText = getEndMarkerText();
|
||||
const fullCommand = `echo "${startMarkerText}"; ${command}; echo "${endMarkerText}"`;
|
||||
|
||||
activeCommands.set(commandId, {
|
||||
id: commandId,
|
||||
paneId,
|
||||
command,
|
||||
status: "pending",
|
||||
startTime: new Date(),
|
||||
rawMode: false,
|
||||
});
|
||||
|
||||
await executeTmux(
|
||||
`send-keys -t '${paneId}' '${fullCommand.replace(/'/g, "'\\''")}' Enter`
|
||||
);
|
||||
|
||||
// Poll for completion
|
||||
const startTime = Date.now();
|
||||
const pollInterval = 100;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
|
||||
const result = await checkCommandStatus(commandId);
|
||||
|
||||
if (result && result.status !== "pending") {
|
||||
// Cleanup
|
||||
activeCommands.delete(commandId);
|
||||
|
||||
return {
|
||||
command: result.command,
|
||||
status: result.status,
|
||||
exitCode: result.exitCode!,
|
||||
output: result.result || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
activeCommands.delete(commandId);
|
||||
throw new Error(
|
||||
`Command timed out after ${timeout}ms. Use capture-pane to check pane state.`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendKeys({
|
||||
paneId,
|
||||
keys,
|
||||
repeat = 1,
|
||||
return_output,
|
||||
}: {
|
||||
paneId: string;
|
||||
keys: string;
|
||||
repeat?: number;
|
||||
return_output?: {
|
||||
lines?: number;
|
||||
waitForSettled?: boolean;
|
||||
maxWait?: number;
|
||||
};
|
||||
}): Promise<string | void> {
|
||||
// Repeat the key press the specified number of times
|
||||
for (let i = 0; i < repeat; i++) {
|
||||
// Raw pass-through, no validation or processing
|
||||
await executeTmux(`send-keys -t '${paneId}' ${keys}`);
|
||||
}
|
||||
|
||||
// If return_output is requested, wait and capture pane content
|
||||
if (return_output) {
|
||||
const lines = return_output.lines || 200;
|
||||
const waitForSettled = return_output.waitForSettled ?? true;
|
||||
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
|
||||
|
||||
if (waitForSettled) {
|
||||
return waitForPaneActivityToSettle(paneId, maxWait, lines);
|
||||
} else {
|
||||
return capturePaneContent(paneId, lines, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForPaneActivityToSettle(
|
||||
paneId: string,
|
||||
maxWait: number,
|
||||
lines: number
|
||||
): Promise<string> {
|
||||
const settleTime = 1000; // Hardcoded debounce
|
||||
const pollInterval = 100; // Poll every 100ms
|
||||
|
||||
let lastContent = "";
|
||||
let lastChangeTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed >= maxWait) {
|
||||
// Timeout - return what we have
|
||||
return lastContent;
|
||||
}
|
||||
|
||||
const content = await capturePaneContent(paneId, lines, false);
|
||||
|
||||
if (content !== lastContent) {
|
||||
// Activity detected - reset settle timer
|
||||
lastContent = content;
|
||||
lastChangeTime = Date.now();
|
||||
} else if (Date.now() - lastChangeTime >= settleTime) {
|
||||
// No changes for settleTime ms - settled!
|
||||
return content;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendText({
|
||||
paneId,
|
||||
text,
|
||||
pressEnter = false,
|
||||
return_output,
|
||||
}: {
|
||||
paneId: string;
|
||||
text: string;
|
||||
pressEnter?: boolean;
|
||||
return_output?: {
|
||||
lines?: number;
|
||||
waitForSettled?: boolean;
|
||||
maxWait?: number;
|
||||
};
|
||||
}): Promise<string | void> {
|
||||
// Send each character with -l flag for literal interpretation
|
||||
for (const char of text) {
|
||||
await executeTmux(
|
||||
`send-keys -l -t '${paneId}' '${char.replace(/'/g, "'\\''")}'`
|
||||
);
|
||||
}
|
||||
|
||||
if (pressEnter) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await executeTmux(`send-keys -t '${paneId}' Enter`);
|
||||
}
|
||||
|
||||
// If return_output is requested, wait and capture pane content
|
||||
if (return_output) {
|
||||
const lines = return_output.lines || 200;
|
||||
const waitForSettled = return_output.waitForSettled ?? true;
|
||||
const maxWait = return_output.maxWait ?? 120000; // 2 minutes default
|
||||
|
||||
if (waitForSettled) {
|
||||
return waitForPaneActivityToSettle(paneId, maxWait, lines);
|
||||
} else {
|
||||
return capturePaneContent(paneId, lines, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user