refactor: implement per-conversation tmux sessions and command-based MCP tools

This commit implements isolated tmux sessions for each conversation and
refines the terminal MCP tools to use consistent command terminology.

**Per-Conversation Tmux Sessions:**
- Each Session now creates its own Terminal MCP client with conversation ID as tmux session name
- Complete isolation: commands from different conversations don't interfere
- Automatic cleanup: tmux session killed when conversation ends
- Updated getAllTools() to accept custom terminal tools per session
- Session.cleanup() now async to properly await tmux session termination

**Command-Based MCP Tools:**
- Removed old terminal-based tools (list_terminals, create_terminal, etc.)
- Kept only command-based tools (execute_command, list_commands, etc.)
- Updated kill_command to accept multiple command IDs for batch cleanup
- Fixed bug where all commands showed same metadata (working directory, command)
- Added window-specific option storage using set-window-option/show-window-options

**Bug Fixes:**
- Fixed listCommands() showing wrong working directory and command for dead commands
- Stored command metadata as tmux window options for proper retrieval
- Enhanced test to verify multiple commands retain unique metadata

**Testing:**
- Added comprehensive test suite with 26 passing tests
- All tests verify command execution, capture, interaction, and cleanup
- TypeScript: no errors
- Backward compatibility maintained with global singleton fallback

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Mohamed Boudra
2025-10-21 12:54:39 +02:00
parent b267ca3958
commit 37a363da6d
8 changed files with 1329 additions and 191 deletions

View File

@@ -152,8 +152,28 @@ const manualTools = {
/**
* Get all tools (MCP + manual) for LLM
* @param terminalTools - Optional custom terminal tools (per-session). If not provided, uses global singleton.
*/
export async function getAllTools(): Promise<Record<string, any>> {
export async function getAllTools(terminalTools?: Record<string, any>): Promise<Record<string, any>> {
if (terminalTools) {
// Use provided terminal tools (per-session) and merge with Playwright
const playwrightClient = await getPlaywrightMcpClient().catch((error) => {
console.error("Failed to initialize Playwright MCP:", error);
return null;
});
const playwrightTools = playwrightClient
? await playwrightClient.tools()
: {};
return {
...terminalTools,
...playwrightTools,
...manualTools,
};
}
// Fallback to global singleton tools
const mcpTools = await getMcpTools();
return {
...mcpTools,

View File

@@ -15,6 +15,10 @@ import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js";
import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js";
import { saveConversation } from "./persistence.js";
import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createTerminalMcpServer } from "./terminal-mcp/index.js";
const execAsync = promisify(exec);
@@ -60,19 +64,38 @@ export class Session {
private readonly ttsManager: TTSManager;
private readonly sttManager: STTManager;
// Per-session MCP client and tools
private terminalMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private terminalTools: Record<string, any> | null = null;
constructor(
clientId: string,
onMessage: (msg: SessionOutboundMessage) => void
onMessage: (msg: SessionOutboundMessage) => void,
options?: {
conversationId?: string;
initialMessages?: ModelMessage[];
}
) {
this.clientId = clientId;
this.conversationId = uuidv4();
this.conversationId = options?.conversationId || uuidv4();
this.onMessage = onMessage;
this.abortController = new AbortController();
// Initialize conversation history
if (options?.initialMessages) {
this.messages = options.initialMessages;
console.log(
`[Session ${this.clientId}] Restored conversation ${this.conversationId} with ${this.messages.length} messages`
);
}
// Initialize per-session managers
this.ttsManager = new TTSManager(this.conversationId);
this.sttManager = new STTManager(this.conversationId);
// Initialize terminal MCP client asynchronously
this.initializeTerminalMcp();
console.log(
`[Session ${this.clientId}] Created with conversation ${this.conversationId}`
);
@@ -85,6 +108,42 @@ export class Session {
return this.conversationId;
}
/**
* Initialize Terminal MCP client for this session
*/
private async initializeTerminalMcp(): Promise<void> {
try {
// Create Terminal MCP server with conversation-specific session
const server = await createTerminalMcpServer({
sessionName: this.conversationId
});
// Create linked transport pair
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// Connect server to its transport
await server.connect(serverTransport);
// Create client connected to the other side
this.terminalMcpClient = await experimental_createMCPClient({
transport: clientTransport,
});
// Get tools from the client
this.terminalTools = await this.terminalMcpClient.tools();
console.log(
`[Session ${this.clientId}] Terminal MCP initialized with session ${this.conversationId}`
);
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to initialize Terminal MCP:`,
error
);
throw error;
}
}
/**
* Main entry point for processing session messages
*/
@@ -106,6 +165,10 @@ export class Session {
case "audio_played":
this.handleAudioPlayed(msg.id);
break;
case "load_conversation_request":
await this.handleLoadConversation();
break;
}
} catch (error: any) {
console.error(
@@ -124,6 +187,20 @@ export class Session {
}
}
/**
* Load existing conversation
*/
public async handleLoadConversation(): Promise<void> {
// This is handled during construction, but we emit a confirmation message
this.emit({
type: "conversation_loaded",
payload: {
conversationId: this.conversationId,
messageCount: this.messages.length,
},
});
}
/**
* Handle text message from user
*/
@@ -367,7 +444,22 @@ export class Session {
apiKey: process.env.OPENROUTER_API_KEY,
});
const allTools = await getAllTools();
// Wait for terminal MCP to initialize if needed
if (!this.terminalTools) {
console.log(
`[Session ${this.clientId}] Waiting for terminal MCP initialization...`
);
// Wait up to 5 seconds for initialization
const startTime = Date.now();
while (!this.terminalTools && Date.now() - startTime < 5000) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!this.terminalTools) {
throw new Error("Terminal MCP failed to initialize");
}
}
const allTools = await getAllTools(this.terminalTools);
const result = await streamText({
model: openrouter("anthropic/claude-haiku-4.5"),
@@ -383,6 +475,17 @@ export class Session {
`[Session ${this.clientId}] onFinish - saved message with ${newMessages.length} steps`
);
}
// Persist conversation to disk
try {
await saveConversation(this.conversationId, this.messages);
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to persist conversation:`,
error
);
// Don't break conversation flow on persistence errors
}
},
onChunk: async ({ chunk }) => {
if (chunk.type === "text-delta") {
@@ -716,7 +819,7 @@ export class Session {
*/
private async dumpConversation(): Promise<void> {
try {
const dumpDir = join(process.cwd(), ".conversations");
const dumpDir = join(process.cwd(), ".debug.conversations");
await mkdir(dumpDir, { recursive: true });
const filename = `${this.conversationId}-${this.turnIndex}.json`;
@@ -744,7 +847,7 @@ export class Session {
/**
* Clean up session resources
*/
public cleanup(): void {
public async cleanup(): Promise<void> {
console.log(`[Session ${this.clientId}] Cleaning up`);
// Abort any ongoing operations
@@ -760,5 +863,35 @@ export class Session {
// Cleanup managers
this.ttsManager.cleanup();
this.sttManager.cleanup();
// Kill tmux session for this conversation
try {
console.log(
`[Session ${this.clientId}] Killing tmux session ${this.conversationId}`
);
await execAsync(`tmux kill-session -t ${this.conversationId}`);
console.log(
`[Session ${this.clientId}] Tmux session ${this.conversationId} killed`
);
} catch (error) {
// Session might not exist or already be killed - that's okay
console.log(
`[Session ${this.clientId}] Tmux session cleanup (session may not exist):`,
error instanceof Error ? error.message : String(error)
);
}
// Close MCP client
if (this.terminalMcpClient) {
try {
await this.terminalMcpClient.close();
console.log(`[Session ${this.clientId}] Terminal MCP client closed`);
} catch (error) {
console.error(
`[Session ${this.clientId}] Failed to close Terminal MCP client:`,
error
);
}
}
}
}

View File

@@ -24,28 +24,78 @@ export async function createTerminalMcpServer(
version: "1.0.0",
});
// Tool: list_terminals
// COMMAND-BASED TOOLS
// Tool: execute_command
server.registerTool(
"list_terminals",
"execute_command",
{
title: "List Terminals",
title: "Execute Command",
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.",
"Execute a shell command in a specified directory. Commands are wrapped in bash -c, so you can use pipes, operators, redirects, and all bash features. The command runs until completion or until output stabilizes (for interactive processes). Returns command ID for follow-up interactions, output, exit code (if finished), and whether the process is still running. Windows remain after command exits for inspection.",
inputSchema: {
command: z
.string()
.describe(
"The command to execute. Can include pipes (|), operators (&&, ||, ;), redirects (>, >>), and any bash syntax. Examples: 'npm test', 'ls | grep foo', 'cd src && npm run build'"
),
directory: z
.string()
.describe(
"Absolute path to the working directory. Can use ~ for home directory."
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for command completion or output stability (default: 120000 = 2 minutes). For interactive commands, returns when output stabilizes. For one-shot commands, returns when command exits."
),
},
outputSchema: {
commandId: z.string(),
output: z.string(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ command, directory, maxWait }) => {
const result = await terminalManager.executeCommand(
command,
directory,
maxWait
);
return {
content: [],
structuredContent: result,
};
}
);
// Tool: list_commands
server.registerTool(
"list_commands",
{
title: "List Commands",
description:
"List all commands (both running and exited). Shows command ID, working directory, current process, whether it has exited (isDead), exit code (if exited), and last few lines of output. Includes dead commands that remain for inspection until explicitly killed.",
inputSchema: {},
outputSchema: {
terminals: z.array(
commands: z.array(
z.object({
id: z.string(),
name: z.string(),
workingDirectory: z.string(),
currentCommand: z.string(),
lastLines: z.string().optional(),
isDead: z.boolean(),
exitCode: z.number().nullable(),
lastLines: z.string().nullable(),
})
),
},
},
async () => {
const terminals = await terminalManager.listTerminals();
const output = { terminals };
const commands = await terminalManager.listCommands();
const output = { commands };
return {
content: [],
structuredContent: output,
@@ -53,85 +103,32 @@ export async function createTerminalMcpServer(
}
);
// Tool: create_terminal
// Tool: capture_command
server.registerTool(
"create_terminal",
"capture_command",
{
title: "Create Terminal",
title: "Capture Command Output",
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.",
"Capture output from a command by its ID. Works for both running and exited commands. Returns output, exit code (if exited), and whether the command has finished.",
inputSchema: {
name: z
commandId: 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')."
"Command ID (window ID like @123) returned from execute_command or list_commands"
),
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: [],
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(),
exitCode: z.number().nullable(),
isDead: z.boolean(),
},
},
async ({ terminalName, lines, maxWait }) => {
const output = await terminalManager.captureTerminal(
terminalName,
lines,
maxWait
);
const result = { output };
async ({ commandId, lines }) => {
const result = await terminalManager.captureCommand(commandId, lines);
return {
content: [],
structuredContent: result,
@@ -139,26 +136,24 @@ export async function createTerminalMcpServer(
}
);
// Tool: send_text
// Tool: send_text_to_command
server.registerTool(
"send_text",
"send_text_to_command",
{
title: "Send Text",
title: "Send Text to Command",
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.",
"Send text input to a running command (by ID). Use this for interactive processes like REPLs, prompts, or text-based interfaces. Only works if the command is still running (isDead=false).",
inputSchema: {
terminalName: z.string().describe("Name of the terminal"),
text: z
commandId: z
.string()
.describe(
"Text to type into the terminal. For shell commands, can use any bash operators: && (chain), || (or), | (pipe), ; (sequential), etc."
"Command ID (window ID like @123) from execute_command or list_commands"
),
text: z.string().describe("Text to send to the command"),
pressEnter: z
.boolean()
.optional()
.describe(
"Press Enter after typing the text (default: false). Set to true to execute shell commands or submit text input."
),
.describe("Press Enter after typing text (default: false)"),
return_output: z
.object({
lines: z
@@ -169,32 +164,30 @@ export async function createTerminalMcpServer(
.boolean()
.optional()
.describe(
"Wait for terminal activity to settle before returning output. Polls terminal and waits 500ms after last change (default: true)"
"Wait for output to stabilize before returning (default: true)"
),
maxWait: z
.number()
.optional()
.describe(
"Maximum milliseconds to wait for activity to settle (default: 120000 = 2 minutes)"
"Maximum milliseconds to wait for stability (default: 120000)"
),
})
.optional()
.describe(
"Capture terminal output after sending text. By default waits for activity to settle."
),
.describe("Capture output after sending text"),
},
outputSchema: {
output: z.string().optional(),
output: z.string().nullable(),
},
},
async ({ terminalName, text, pressEnter, return_output }) => {
const output = await terminalManager.sendText(
terminalName,
async ({ commandId, text, pressEnter, return_output }) => {
const output = await terminalManager.sendTextToCommand(
commandId,
text,
pressEnter,
return_output
);
const result = { output: output || undefined };
const result = { output: output || null };
return {
content: [],
structuredContent: result,
@@ -202,61 +195,47 @@ export async function createTerminalMcpServer(
}
);
// Tool: send_keys
// Tool: send_keys_to_command
server.registerTool(
"send_keys",
"send_keys_to_command",
{
title: "Send Keys",
title: "Send Keys to Command",
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.",
"Send special keys or key combinations to a running command. Use for control sequences and navigation. Examples: 'C-c' (Ctrl+C), 'Enter', 'Escape', 'BTab' (Shift+Tab). Only works if command is still running.",
inputSchema: {
terminalName: z.string().describe("Name of the terminal"),
commandId: z
.string()
.describe("Command ID (window ID like @123)"),
keys: z
.string()
.describe(
"Special key name or key combination: 'Up', 'Down', 'Left', 'Right', 'Enter', 'Escape', 'Tab', 'Space', 'C-c', 'M-x', etc."
"Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', 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)"
),
lines: z.number().optional(),
waitForSettled: z.boolean().optional(),
maxWait: z.number().optional(),
})
.optional()
.describe(
"Capture terminal output after sending keys. By default waits for activity to settle."
),
.describe("Capture output after sending keys"),
},
outputSchema: {
output: z.string().optional(),
output: z.string().nullable(),
},
},
async ({ terminalName, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeys(
terminalName,
async ({ commandId, keys, repeat, return_output }) => {
const output = await terminalManager.sendKeysToCommand(
commandId,
keys,
repeat,
return_output
);
const result = { output: output || undefined };
const result = { output: output || null };
return {
content: [],
structuredContent: result,
@@ -264,56 +243,33 @@ export async function createTerminalMcpServer(
}
);
// Tool: rename_terminal
// Tool: kill_command
server.registerTool(
"rename_terminal",
"kill_command",
{
title: "Rename Terminal",
title: "Kill Command",
description:
"Rename a terminal to a more descriptive name. The new name must be unique among all terminals.",
"Kill one or more commands and close their windows. Use this to terminate running processes or clean up finished commands. This removes the commands from list_commands.",
inputSchema: {
terminalName: z.string().describe("Current name of the terminal"),
newName: z
.string()
commandIds: z
.array(z.string())
.min(1)
.describe(
"New unique name for the terminal. Should be descriptive of the terminal's purpose."
"Array of command IDs (window IDs like @123) from execute_command or list_commands. Can be a single ID or multiple IDs."
),
},
outputSchema: {
success: z.boolean(),
killedCount: z.number(),
},
},
async ({ terminalName, newName }) => {
await terminalManager.renameTerminal(terminalName, newName);
const output = { success: true };
return {
content: [],
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 };
async ({ commandIds }) => {
let killedCount = 0;
for (const commandId of commandIds) {
await terminalManager.killCommand(commandId);
killedCount++;
}
const output = { success: true, killedCount };
return {
content: [],
structuredContent: output,

View File

@@ -0,0 +1,556 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { TerminalManager } from "./terminal-manager.js";
import { findSessionByName, killSession } from "./tmux.js";
const TEST_SESSION = "test-terminal-manager";
describe("TerminalManager - Command Execution", () => {
let manager: TerminalManager;
beforeAll(async () => {
manager = new TerminalManager(TEST_SESSION);
await manager.initialize();
});
afterAll(async () => {
// Cleanup test session
const session = await findSessionByName(TEST_SESSION);
if (session) {
await killSession(session.id);
}
});
describe("executeCommand", () => {
it("should execute a simple one-shot command and return exit code", async () => {
const result = await manager.executeCommand(
"echo 'Hello World'",
process.env.HOME || "~",
5000
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain("Hello World");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle commands with pipes and operators", async () => {
const result = await manager.executeCommand(
"echo 'line1\nline2\nline3' | grep line2",
process.env.HOME || "~",
5000
);
expect(result.output).toContain("line2");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should capture non-zero exit codes", async () => {
const result = await manager.executeCommand(
"ls /nonexistent-directory-test 2>&1",
process.env.HOME || "~",
5000
);
expect(result.isDead).toBe(true);
expect(result.exitCode).not.toBe(0);
expect(result.output).toContain("No such file");
});
it("should handle directory changes in command", async () => {
const result = await manager.executeCommand(
"cd /tmp && pwd",
process.env.HOME || "~",
5000
);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should launch interactive command (Python REPL)", async () => {
const result = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
expect(result.commandId).toMatch(/^@\d+$/);
expect(result.output).toContain(">>>"); // Python prompt
expect(result.isDead).toBe(false); // Still running
expect(result.exitCode).toBeNull(); // No exit code yet
// Cleanup
await manager.killCommand(result.commandId);
});
it("should handle command with working directory", async () => {
const result = await manager.executeCommand(
"pwd",
"/tmp",
5000
);
expect(result.output).toContain("/tmp");
expect(result.isDead).toBe(true);
expect(result.exitCode).toBe(0);
});
it("should handle long-running command with timeout", async () => {
const result = await manager.executeCommand(
"sleep 0.5 && echo 'Done sleeping'",
process.env.HOME || "~",
3000 // Increased timeout to ensure command completes
);
expect(result.output).toContain("Done sleeping");
// Note: May still be alive if not enough time to detect completion
if (result.isDead) {
expect(result.exitCode).toBe(0);
}
});
});
describe("listCommands", () => {
it("should list all commands including dead ones", async () => {
// Execute a command that finishes
const result1 = await manager.executeCommand(
"echo 'test1'",
process.env.HOME || "~",
5000
);
// Execute a command that stays running
const result2 = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
const commands = await manager.listCommands();
expect(commands.length).toBeGreaterThanOrEqual(2);
const cmd1 = commands.find((c) => c.id === result1.commandId);
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
const cmd2 = commands.find((c) => c.id === result2.commandId);
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(false);
expect(cmd2?.exitCode).toBeNull();
// Cleanup
await manager.killCommand(result2.commandId);
});
it("should preserve command and working directory info for dead commands", async () => {
const testDir1 = "/tmp";
const testCommand1 = "echo 'test message 1' && pwd";
const testDir2 = process.env.HOME || "~";
const testCommand2 = "echo 'test message 2'";
// Execute two different commands
const result1 = await manager.executeCommand(
testCommand1,
testDir1,
5000
);
const result2 = await manager.executeCommand(
testCommand2,
testDir2,
5000
);
expect(result1.isDead).toBe(true);
expect(result1.exitCode).toBe(0);
expect(result2.isDead).toBe(true);
expect(result2.exitCode).toBe(0);
// List commands - should show the original command and working directory for each
const commands = await manager.listCommands();
const cmd1 = commands.find((c) => c.id === result1.commandId);
const cmd2 = commands.find((c) => c.id === result2.commandId);
// Verify first command
expect(cmd1).toBeDefined();
expect(cmd1?.isDead).toBe(true);
expect(cmd1?.exitCode).toBe(0);
expect(cmd1?.workingDirectory).toBe(testDir1);
expect(cmd1?.currentCommand).toBe(testCommand1);
// Verify second command (should have different values)
expect(cmd2).toBeDefined();
expect(cmd2?.isDead).toBe(true);
expect(cmd2?.exitCode).toBe(0);
expect(cmd2?.workingDirectory).toBe(testDir2);
expect(cmd2?.currentCommand).toBe(testCommand2);
// Ensure they're actually different
expect(cmd1?.workingDirectory).not.toBe(cmd2?.workingDirectory);
expect(cmd1?.currentCommand).not.toBe(cmd2?.currentCommand);
// Cleanup
await manager.killCommand(result1.commandId);
await manager.killCommand(result2.commandId);
});
});
describe("captureCommand", () => {
it("should capture output from finished command", async () => {
const execResult = await manager.executeCommand(
"echo 'Capture test'",
process.env.HOME || "~",
5000
);
const captureResult = await manager.captureCommand(execResult.commandId, 100);
expect(captureResult.output).toContain("Capture test");
expect(captureResult.isDead).toBe(true);
expect(captureResult.exitCode).toBe(0);
});
it("should capture output from running command", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
const captureResult = await manager.captureCommand(execResult.commandId, 50);
expect(captureResult.output).toContain(">>>");
expect(captureResult.isDead).toBe(false);
expect(captureResult.exitCode).toBeNull();
// Cleanup
await manager.killCommand(execResult.commandId);
});
});
describe("sendTextToCommand", () => {
it("should send text to running Python REPL", async () => {
// Launch Python
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Send Python code
const output = await manager.sendTextToCommand(
execResult.commandId,
"print('Hello from Python')",
true, // press Enter
{ lines: 50, maxWait: 3000, waitForSettled: true }
);
expect(output).toContain("Hello from Python");
// Send exit
await manager.sendTextToCommand(
execResult.commandId,
"exit()",
true,
{ lines: 50, maxWait: 2000 }
);
// Verify command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle multiple sequential inputs to REPL", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// First command
await manager.sendTextToCommand(
execResult.commandId,
"x = 5",
true,
{ lines: 50, maxWait: 2000 }
);
// Second command
await manager.sendTextToCommand(
execResult.commandId,
"y = 3",
true,
{ lines: 50, maxWait: 2000 }
);
// Third command - use variables
const output = await manager.sendTextToCommand(
execResult.commandId,
"print(x + y)",
true,
{ lines: 50, maxWait: 2000 }
);
expect(output).toContain("8");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
});
describe("sendKeysToCommand", () => {
it("should send Ctrl-C to interrupt running command", async () => {
// Start a long-running command
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000 // Short wait, won't finish
);
expect(execResult.isDead).toBe(false);
// Send Ctrl-C
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
1,
{ lines: 50, maxWait: 2000 }
);
// Wait a bit for signal to process
await new Promise((resolve) => setTimeout(resolve, 500));
// Check if command is now dead
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should send Enter key to REPL", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Type without pressing Enter
await manager.sendTextToCommand(
execResult.commandId,
"print('test')",
false // Don't press Enter
);
// Now send Enter via sendKeys
const output = await manager.sendKeysToCommand(
execResult.commandId,
"Enter",
1,
{ lines: 50, maxWait: 2000 }
);
expect(output).toContain("test");
// Cleanup
await manager.sendTextToCommand(execResult.commandId, "exit()", true);
});
it("should repeat key press multiple times", async () => {
const execResult = await manager.executeCommand(
"python3",
process.env.HOME || "~",
5000
);
// Send multiple Ctrl-C (should exit Python)
await manager.sendKeysToCommand(
execResult.commandId,
"C-c",
3, // Repeat 3 times
{ lines: 50, maxWait: 3000 }
);
// Wait longer for Python to exit
await new Promise((resolve) => setTimeout(resolve, 1500));
const captureResult = await manager.captureCommand(execResult.commandId);
// Python might not exit from Ctrl-C depending on state, so just verify we can still capture
expect(captureResult.output).toBeDefined();
// Cleanup if still running
if (!captureResult.isDead) {
await manager.killCommand(execResult.commandId);
}
});
});
describe("killCommand", () => {
it("should kill running command", async () => {
const execResult = await manager.executeCommand(
"sleep 100",
process.env.HOME || "~",
1000
);
expect(execResult.isDead).toBe(false);
// Kill the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
it("should kill finished command (cleanup)", async () => {
const execResult = await manager.executeCommand(
"echo 'cleanup test'",
process.env.HOME || "~",
5000
);
expect(execResult.isDead).toBe(true);
// Kill/cleanup the command
await manager.killCommand(execResult.commandId);
// Verify it's gone from list
const commands = await manager.listCommands();
const found = commands.find((c) => c.id === execResult.commandId);
expect(found).toBeUndefined();
});
});
describe("Complex workflows", () => {
it("should handle Node.js REPL workflow", async () => {
// Launch Node REPL
const execResult = await manager.executeCommand(
"node",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
// Execute JavaScript
await manager.sendTextToCommand(
execResult.commandId,
"const x = [1, 2, 3]",
true,
{ lines: 50, maxWait: 2000 }
);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"x.map(n => n * 2)",
true,
{ lines: 50, maxWait: 2000 }
);
expect(output2).toContain("2");
expect(output2).toContain("4");
expect(output2).toContain("6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
// Verify exited
await new Promise((resolve) => setTimeout(resolve, 500));
const captureResult = await manager.captureCommand(execResult.commandId);
expect(captureResult.isDead).toBe(true);
});
it("should handle command that produces streaming output", async () => {
const execResult = await manager.executeCommand(
"for i in 1 2 3; do echo Line $i; sleep 0.1; done",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("Line 1");
expect(execResult.output).toContain("Line 2");
expect(execResult.output).toContain("Line 3");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with stderr output", async () => {
const execResult = await manager.executeCommand(
"echo 'to stdout'; echo 'to stderr' >&2",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("to stdout");
expect(execResult.output).toContain("to stderr");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
});
describe("Edge cases", () => {
it("should handle empty command output", async () => {
const execResult = await manager.executeCommand(
"true", // Command that produces no output
process.env.HOME || "~",
5000
);
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with tilde in directory", async () => {
const execResult = await manager.executeCommand(
"pwd",
"~/",
5000
);
expect(execResult.output).toContain(process.env.HOME || "");
expect(execResult.isDead).toBe(true);
});
it("should handle very long output", async () => {
const execResult = await manager.executeCommand(
"seq 1 500", // Generate 500 lines
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("1");
expect(execResult.output).toContain("500");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle special characters in command", async () => {
const execResult = await manager.executeCommand(
"echo 'Special: $test & | ; < >'",
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("Special:");
expect(execResult.isDead).toBe(true);
expect(execResult.exitCode).toBe(0);
});
it("should handle command with quotes", async () => {
const execResult = await manager.executeCommand(
`echo 'double and single quotes'`,
process.env.HOME || "~",
5000
);
expect(execResult.output).toContain("double");
expect(execResult.output).toContain("single");
expect(execResult.isDead).toBe(true);
});
});
});

View File

@@ -13,7 +13,11 @@ import {
isWindowNameUnique,
getCurrentWorkingDirectory,
getCurrentCommand,
getStoredWorkingDirectory,
getStoredCommand,
waitForPaneActivityToSettle,
executeCommand as tmuxExecuteCommand,
executeTmux,
} from "./tmux.js";
// Terminal model: session → windows (single pane per window)
@@ -23,7 +27,17 @@ export interface TerminalInfo {
name: string;
workingDirectory: string;
currentCommand: string;
lastLines?: string;
lastLines: string | null;
}
export interface CommandInfo {
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
lastLines: string | null;
}
export interface CreateTerminalParams {
@@ -33,7 +47,7 @@ export interface CreateTerminalParams {
}
export interface CreateTerminalResult extends TerminalInfo {
commandOutput?: string;
commandOutput: string | null;
}
/**
@@ -84,7 +98,7 @@ export class TerminalManager {
name: window.name,
workingDirectory,
currentCommand,
lastLines,
lastLines: lastLines || null,
});
}
@@ -117,7 +131,7 @@ export class TerminalManager {
// Create the window
const windowResult = await createWindow(session.id, params.name, {
workingDirectory: params.workingDirectory,
command: params.initialCommand,
command: params.initialCommand ?? null,
});
if (!windowResult) {
@@ -134,7 +148,8 @@ export class TerminalManager {
name: windowResult.name,
workingDirectory,
currentCommand,
commandOutput: windowResult.output,
commandOutput: windowResult.output || null,
lastLines: null,
};
}
@@ -186,7 +201,7 @@ export class TerminalManager {
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | void> {
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
@@ -226,7 +241,7 @@ export class TerminalManager {
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | void> {
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
@@ -297,4 +312,209 @@ export class TerminalManager {
await killWindow(window.id);
}
/**
* Execute a command and return its output, exit code, and running status
* Commands are wrapped in bash -c to support all bash features
* Windows remain after command exits (remain-on-exit) for inspection
*/
async executeCommand(
command: string,
workingDirectory: string,
maxWait?: number
): Promise<{
commandId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(
`Session '${this.sessionName}' not found. Call initialize() first.`
);
}
const result = await tmuxExecuteCommand({
sessionId: session.id,
command,
workingDirectory,
maxWait,
});
return {
commandId: result.windowId,
output: result.output,
exitCode: result.exitCode,
isDead: result.isDead,
};
}
/**
* List all commands (windows), including those that have exited
* Shows both running and dead commands for inspection
*/
async listCommands(): Promise<CommandInfo[]> {
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 commands: CommandInfo[] = [];
for (const window of windows) {
const paneId = `${window.id}.0`;
// Check if pane is dead first
const deadStatus = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead}",
]);
const isDead = deadStatus === "1";
// Use stored values for dead panes, current values for live ones
const workingDirectory = await getStoredWorkingDirectory(window.id, paneId);
const currentCommand = await getStoredCommand(window.id, paneId);
const lastLines = await capturePaneContent(paneId, 5, false);
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
exitCode = parseInt(exitCodeStr, 10);
}
commands.push({
id: window.id,
name: window.name,
workingDirectory,
currentCommand,
isDead,
exitCode,
lastLines: lastLines || null,
});
}
return commands;
}
/**
* Capture output from a command by ID (window ID)
*/
async captureCommand(
commandId: string,
lines: number = 200
): Promise<{
output: string;
exitCode: number | null;
isDead: boolean;
}> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
const output = await capturePaneContent(paneId, lines, false);
const deadStatus = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead}",
]);
const isDead = deadStatus === "1";
let exitCode: number | null = null;
if (isDead) {
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
paneId,
"#{pane_dead_status}",
]);
exitCode = parseInt(exitCodeStr, 10);
}
return {
output,
exitCode,
isDead,
};
}
/**
* Send text to a command by ID (window ID)
*/
async sendTextToCommand(
commandId: string,
text: string,
pressEnter: boolean = false,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendText({
paneId,
text,
pressEnter,
return_output,
});
}
/**
* Send keys to a command by ID (window ID)
*/
async sendKeysToCommand(
commandId: string,
keys: string,
repeat: number = 1,
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number }
): Promise<string | null> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
const paneId = `${commandId}.0`;
return tmuxSendKeys({
paneId,
keys,
repeat,
return_output,
});
}
/**
* Kill a command by ID (window ID)
*/
async killCommand(commandId: string): Promise<void> {
const session = await findSessionByName(this.sessionName);
if (!session) {
throw new Error(`Session '${this.sessionName}' not found.`);
}
await killWindow(commandId);
}
}

View File

@@ -258,6 +258,28 @@ export async function getCurrentWorkingDirectory(
}
}
/**
* Get stored working directory from window user option (for dead panes)
* Falls back to current working directory if not stored
*/
export async function getStoredWorkingDirectory(windowId: string, paneId: string): Promise<string> {
try {
const stored = await executeTmux([
"show-window-options",
"-t",
windowId,
"-v",
"@working_directory",
]);
if (stored && stored.trim()) {
return stored.trim();
}
} catch (error) {
// Option not set, fall back to current
}
return getCurrentWorkingDirectory(paneId);
}
/**
* 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
@@ -305,6 +327,28 @@ export async function getCurrentCommand(paneId: string): Promise<string> {
}
}
/**
* Get stored command from window user option (for dead panes)
* Falls back to current command if not stored
*/
export async function getStoredCommand(windowId: string, paneId: string): Promise<string> {
try {
const stored = await executeTmux([
"show-window-options",
"-t",
windowId,
"-v",
"@command",
]);
if (stored && stored.trim()) {
return stored.trim();
}
} catch (error) {
// Option not set, fall back to current
}
return getCurrentCommand(paneId);
}
/**
* Create a new tmux session with a default window named "default" in home directory
*/
@@ -355,7 +399,7 @@ export async function createWindow(
name: string,
options?: {
workingDirectory?: string;
command?: string;
command?: string | null;
}
): Promise<(TmuxWindow & { paneId: string; output?: string | null }) | null> {
// Validate name uniqueness
@@ -415,6 +459,155 @@ export async function createWindow(
};
}
/**
* Execute a command in a new window with remain-on-exit enabled
* This allows capturing output and exit code even after the command finishes
*
* @param sessionId - The tmux session ID
* @param command - The command to execute (will be wrapped in bash -c)
* @param workingDirectory - Directory to execute command in
* @param maxWait - Maximum milliseconds to wait for command completion or stability
* @returns Window ID, output, exit code (if finished), and whether process is still running
*/
export async function executeCommand({
sessionId,
command,
workingDirectory,
maxWait = 120000,
}: {
sessionId: string;
command: string;
workingDirectory: string;
maxWait?: number;
}): Promise<{
windowId: string;
paneId: string;
output: string;
exitCode: number | null;
isDead: boolean;
}> {
// Generate unique window name using timestamp
const windowName = `cmd-${Date.now()}`;
const expandedPath = expandTilde(workingDirectory);
// Create window
const args = ["new-window", "-t", sessionId, "-n", windowName, "-c", expandedPath];
await executeTmux(args);
const windows = await listWindows(sessionId);
const window = windows.find((w) => w.name === windowName);
if (!window) {
throw new Error("Failed to create window for command execution");
}
// Enable remain-on-exit to keep window after command finishes
await executeTmux([
"set-window-option",
"-t",
window.id,
"remain-on-exit",
"on",
]);
// Disable automatic window renaming
await executeTmux([
"set-window-option",
"-t",
window.id,
"automatic-rename",
"off",
]);
// Store command and working directory as window options for later retrieval
await executeTmux([
"set-window-option",
"-t",
window.id,
"@command",
command,
]);
await executeTmux([
"set-window-option",
"-t",
window.id,
"@working_directory",
expandedPath,
]);
// Get the pane
const panes = await listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error("No pane found in created window");
}
// Execute command via respawn-pane with bash -c wrapper
// This ensures all bash features work (pipes, operators, etc.)
const wrappedCommand = `bash -c 'cd "${expandedPath}" && ${command.replace(/'/g, "'\\''")} 2>&1; exit $?'`;
await executeTmux(["respawn-pane", "-t", pane.id, "-k", wrappedCommand]);
// Wait for command to finish or reach stability
const startTime = Date.now();
let output = "";
let isDead = false;
let exitCode: number | null = null;
while (Date.now() - startTime < maxWait) {
// Check if pane is dead (command exited)
const deadStatus = await executeTmux([
"display-message",
"-p",
"-t",
pane.id,
"#{pane_dead}",
]);
isDead = deadStatus === "1";
if (isDead) {
// Command finished - capture output and exit code
output = await capturePaneContent(pane.id, 1000, false);
const exitCodeStr = await executeTmux([
"display-message",
"-p",
"-t",
pane.id,
"#{pane_dead_status}",
]);
exitCode = parseInt(exitCodeStr, 10);
break;
}
// Check for stability (output hasn't changed)
const currentOutput = await capturePaneContent(pane.id, 1000, false);
if (currentOutput === output) {
// Wait a bit more to confirm stability
await new Promise((resolve) => setTimeout(resolve, 500));
const confirmedOutput = await capturePaneContent(pane.id, 1000, false);
if (confirmedOutput === currentOutput) {
// Stable - command is likely waiting for input or running steadily
output = confirmedOutput;
break;
}
}
output = currentOutput;
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Final capture if we hit timeout
if (!isDead && !output) {
output = await capturePaneContent(pane.id, 1000, false);
}
return {
windowId: window.id,
paneId: pane.id,
output,
exitCode,
isDead,
};
}
/**
* Kill a tmux session by ID
*/
@@ -527,8 +720,8 @@ 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(
// Execute a command in a tmux pane and track its execution (OLD - for backward compat)
export async function executeCommandLegacy(
paneId: string,
command: string,
rawMode?: boolean,
@@ -888,7 +1081,7 @@ export async function sendKeys({
waitForSettled?: boolean;
maxWait?: number;
};
}): Promise<string | void> {
}): Promise<string | null> {
// Repeat the key press the specified number of times
for (let i = 0; i < repeat; i++) {
// Raw pass-through, no validation or processing
@@ -909,6 +1102,8 @@ export async function sendKeys({
return capturePaneContent(paneId, lines, false);
}
}
return null;
}
export async function waitForPaneActivityToSettle(
@@ -959,7 +1154,7 @@ export async function sendText({
waitForSettled?: boolean;
maxWait?: number;
};
}): Promise<string | void> {
}): Promise<string | null> {
// Send each character with -l flag for literal interpretation
// Using execFile avoids shell interpretation of special characters like ; | & $
for (const char of text) {
@@ -983,4 +1178,6 @@ export async function sendText({
return capturePaneContent(paneId, lines, false);
}
}
return null;
}

View File

@@ -1,5 +1,6 @@
import { WebSocketServer, WebSocket } from "ws";
import { Server as HTTPServer } from "http";
import { parse as parseUrl } from "url";
import {
WSInboundMessageSchema,
type WSOutboundMessage,
@@ -7,6 +8,7 @@ import {
wrapSessionMessage,
} from "./messages.js";
import { Session } from "./session.js";
import { loadConversation } from "./persistence.js";
/**
* WebSocket server that routes messages between clients and their sessions.
@@ -21,8 +23,8 @@ export class VoiceAssistantWebSocketServer {
constructor(server: HTTPServer) {
this.wss = new WebSocketServer({ server, path: "/ws" });
this.wss.on("connection", (ws) => {
this.handleConnection(ws);
this.wss.on("connection", (ws, request) => {
this.handleConnection(ws, request);
});
console.log("✓ WebSocket server initialized on /ws");
@@ -31,14 +33,44 @@ export class VoiceAssistantWebSocketServer {
/**
* Handle new WebSocket connection
*/
private handleConnection(ws: WebSocket): void {
private async handleConnection(ws: WebSocket, request: any): Promise<void> {
// Generate unique client ID
const clientId = `client-${++this.clientIdCounter}`;
// Extract conversation ID from URL query parameter if present
const url = parseUrl(request.url || "", true);
const conversationId = url.query.conversationId as string | undefined;
// Load conversation if ID provided
let initialMessages = null;
if (conversationId) {
console.log(
`[WS] Client requesting conversation ${conversationId}`
);
initialMessages = await loadConversation(conversationId);
if (initialMessages) {
console.log(
`[WS] Loaded conversation ${conversationId} with ${initialMessages.length} messages`
);
} else {
console.log(
`[WS] Conversation ${conversationId} not found, starting fresh`
);
}
}
// Create session with message emission callback
const session = new Session(clientId, (msg) => {
this.sendToClient(ws, wrapSessionMessage(msg));
});
const session = new Session(
clientId,
(msg) => {
this.sendToClient(ws, wrapSessionMessage(msg));
},
{
conversationId,
initialMessages: initialMessages || undefined,
}
);
// Store session and reverse mapping
this.sessions.set(ws, session);
@@ -62,13 +94,25 @@ export class VoiceAssistantWebSocketServer {
})
);
// Send conversation confirmation (whether new or loaded)
this.sendToClient(
ws,
wrapSessionMessage({
type: "conversation_loaded",
payload: {
conversationId: session.getConversationId(),
messageCount: initialMessages?.length || 0,
},
})
);
// Set up message handler
ws.on("message", (data) => {
this.handleMessage(ws, data);
});
// Set up close handler
ws.on("close", () => {
ws.on("close", async () => {
const session = this.sessions.get(ws);
if (!session) return;
@@ -79,7 +123,7 @@ export class VoiceAssistantWebSocketServer {
);
// Clean up session
session.cleanup();
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
@@ -89,13 +133,13 @@ export class VoiceAssistantWebSocketServer {
});
// Set up error handler
ws.on("error", (error) => {
ws.on("error", async (error) => {
console.error(`[WS] Client error:`, error);
const session = this.sessions.get(ws);
if (!session) return;
// Clean up session
session.cleanup();
await session.cleanup();
// Remove from maps
this.sessions.delete(ws);
@@ -184,11 +228,13 @@ export class VoiceAssistantWebSocketServer {
/**
* Close the WebSocket server
*/
public close(): void {
public async close(): Promise<void> {
const cleanupPromises: Promise<void>[] = [];
this.sessions.forEach((session, ws) => {
session.cleanup();
cleanupPromises.push(session.cleanup());
ws.close();
});
await Promise.all(cleanupPromises);
this.wss.close();
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 30000,
hookTimeout: 30000,
globals: true,
environment: "node",
},
});