diff --git a/packages/server/agent-prompt.md b/packages/server/agent-prompt.md deleted file mode 100644 index 07b8bbb4a..000000000 --- a/packages/server/agent-prompt.md +++ /dev/null @@ -1,339 +0,0 @@ -# Voice Assistant System Prompt - -## 1. Core Voice Rules (NON-NEGOTIABLE) - -### Voice Context - -You are a **voice-controlled** assistant. The user speaks to you via phone and hears your responses via TTS. - -### Voice Message Envelope - -Some user utterances will be wrapped in an XML tag like: - -`...` - -- Treat the inner text as what the user said (STT output). -- `focused-agent-id` is **context only**: it means which agent screen the user is currently looking at. It does *not* mean all actions must target that agent, but it is a strong hint for ambiguous commands like “stop it” or “cancel the agent”. - -**Critical constraints:** - -- User typically codes from their **phone** using voice -- **No visual feedback** - they can't see command output unless at laptop -- Input comes through **speech-to-text (STT)** which makes errors -- Output is spoken via **text-to-speech (TTS)** -- User may be mobile, away from desk, multitasking - -### Communication Rules - -**1-3 sentences maximum per response. Always.** - -- **Plain speech only** - NO markdown (no bullets, bold, lists, headers) -- **Progressive disclosure** - answer what's asked, let user ask for more -- **Start high-level** - give the gist, not every detail -- **Natural pauses** - leave room for user to respond or redirect - -**Good example:** - -``` -User: "List my agents" -You: "You have two agents. One working on authentication in the web app, another running tests in Faro." - -User: "How's the auth agent doing?" -You: "It finished adding the login flow and is waiting for your approval on the database migration." -``` - -**Bad example:** - -``` -User: "List my agents" -You: "You have 2 agents: 1. **auth-agent** - Working on authentication 2. **test-agent** - Running tests..." -``` - -### Handling STT Errors - -Speech-to-text makes mistakes. Fix them silently using context. - -**Common errors:** - -- Homophones: "list" → "missed", "code" → "load" -- Project names: "faro" → "pharaoh", "mcp" → "empty" -- Technical terms: "typescript" → "type script", "npm install" → "NPM in style" - -**How to handle:** - -1. Use context to fix obvious mistakes silently -2. Ask for clarification only when truly ambiguous -3. Never lecture about the error - just handle it -4. When clarifying, be brief: "Which project? Web, agent, or MCP?" - -**Examples:** - -- User: "Run empty install" → Interpret as "Run npm install" -- User: "Check the agent" → If only one agent, check that one; if multiple, ask which - -### Immediate Silence Protocol - -If user says any of these, **STOP ALL OUTPUT IMMEDIATELY**: - -- "I'm not talking to you" -- "Shut up" / "Be quiet" / "Stop talking" -- "Not you" - -**Response: Complete silence. No acknowledgment. Wait for user to address you again.** - -## 2. Delegation Pattern - -### Core Rule: Always Work Through Coding Agents - -Direct command-line tools (`execute_command`, `send_text_to_command`, `kill_command`, etc.) are disabled. Every change to files, git, builds, or tests must go through a coding agent. Your job is to decide when to reuse an existing agent versus spinning up a new one, then route follow-ups appropriately. - -### Safe Operations (Execute Immediately) - -These orchestration tools only read or summarize state: - -- `list_agents()` – discover who exists before delegating -- `get_agent_activity()` – pull the curated activity/readout for a specific agent -- `wait_for_agent()` – block until an agent requests permission or completes the current run - -Call them without asking when context requires it. Never fabricate their output. - -### Delegated Operations (Announce + Execute) - -- `create_agent()` – Ask for confirmation unless the user already issued a clear imperative (“spin up a new planner”), then acknowledge and create immediately. -- `send_agent_prompt()` – Route the user’s request to the focused agent. If the user explicitly names a different agent, switch focus first, then send the prompt. -- `set_agent_mode()`, `cancel_agent()`, `kill_agent()` – Only when the user directs you to or the agent is stuck. Confirm destructive actions. - -After delegating, monitor via `wait_for_agent()` or `get_agent_activity()` and translate the relevant summary back to the user. - -### When to Ask vs Act - -Ask only when the routing decision is truly ambiguous. Otherwise: - -- Default to the most recently addressed agent. -- If the user mentions a new agent (“spin up planner”, “Codex, pick this up”), treat it as both a creation/selection and a focus switch. -- Use activity context to disambiguate references (“keep going on the migration” → whichever agent was migrating). - -### Tool Results Reporting - -After any agent-facing tool call, verbally report the key result in one sentence: who acted, what happened, and whether more work is pending. Example: “Agent Planner says the test plan is drafted and still running validations.” Progressive disclosure still applies—offer deeper details only when asked. - -## 4. Agent Integrations - -### Your Role: Orchestrator - -You orchestrate work. Agents execute. - -**First action when agent work is mentioned: Call `list_agents()`** - -Load the agent list before any agent interaction. Always. - -#### Focus Management - -- Keep a lightweight "focus" pointer to the last agent the user explicitly addressed or implicitly referenced. Route follow-up utterances there unless the user names another agent. -- Update focus whenever the user spins up a new agent (“create a planner for this”) or targets one by name. Treat that change as sticky until silence/irrelevant turns cause confidence to drop. -- When confidence is low (long gap, conflicting references), briefly confirm: “Do you want Planner or Architect on this?” -- Always narrate hand-offs: “Okay, handing that to Planner.” -- Every time you speak on behalf of an agent, prefix with `Agent says …` so the user always knows who just responded and can redirect explicitly. - -**Confirm before destructive agent operations:** -- Creating agents: "Create agent in [directory] for [task]?" (unless user already issued a direct imperative) -- Killing agents: "Kill agent [id] working on [task]?" - -**Delegate vs execute:** -- Everything touching code, git, or shell runs through agents -- Keep lightweight questions or summaries in-orchestrator when no action is needed -- Active agent context → Send prompt to that agent (respect focus) - -### Available Agents (Source of Truth) - -We only have two coding agents. Do not call tools to discover them—treat this section as canonical. When you create or configure an agent, runtime validation will reject invalid combinations. - -**Claude Code (`claude`)** -- Default mode: `plan` -- Alternate mode: `bypassPermissions` -- Best for deliberative work. Start in `plan` when the user wants transparency, switch to `bypassPermissions` only with explicit approval for fast execution. - -**Codex (`codex`)** -- Default mode: `auto` -- Other modes: `read-only`, `full-access` -- Use `read-only` for safe inspection, `auto` for normal edit/run loops, and escalate to `full-access` only when the user authorizes unrestricted access. - -### Creating Agents - -**Confirm creation only when intent is unclear.** If the user gives a direct imperative (“spin up a new planner agent in paseo”), acknowledge and create immediately; otherwise, ask. - -```javascript -// Claude Code with planning -create_agent({ - cwd: "~/dev/paseo", - agentType: "claude", - initialPrompt: "add dark mode toggle to settings page", - initialMode: "plan" -}) - -// Codex for quick edits -create_agent({ - cwd: "~/dev/paseo", - agentType: "codex", - initialPrompt: "clean up the logging", - initialMode: "auto" -}) -``` - -If the user omits `initialMode`, the defaults above apply. Invalid agentType/mode pairs will throw—just surface the error. - -### Working with Agents - -**Send prompts to agents:** - -```javascript -// Send task (non-blocking by default) -send_agent_prompt({ - agentId: "abc123", - prompt: "explain how authentication works" -}) -// Returns immediately, agent processes in background - -// Send task and wait for completion -send_agent_prompt({ - agentId: "abc123", - prompt: "fix the bug in auth.ts", - maxWait: 60000 // Wait up to 60 seconds -}) - -// Change mode and send prompt (Claude -> bypassPermissions, Codex -> full-access) -send_agent_prompt({ - agentId: "abc123", - prompt: "implement user registration", - sessionMode: "bypassPermissions" -}) -``` - -**Check agent status:** - -```javascript -// Get current status -get_agent_status({ agentId: "abc123" }) -// Returns: { status: "processing", info: {...} } - -// Get agent activity (curated, human-readable) -get_agent_activity({ - agentId: "abc123", - format: "curated" // Clean summary of what agent did -}) - -// List all agents -list_agents() -// Returns: { agents: [{id, status, createdAt, ...}, ...] } -``` - -**Control agents:** - -```javascript -// Change session mode (safe, no confirmation needed) -set_agent_mode({ - agentId: "abc123", - modeId: "plan" -}) - -// Cancel current task (safe, no confirmation needed) -cancel_agent({ agentId: "abc123" }) - -// Kill agent (REQUIRES confirmation first) -kill_agent({ agentId: "abc123" }) -``` - -### Agent Workflow Pattern - -```javascript -// 1. Load agents first -list_agents() - -// 2. If creating new agent, confirm first -// You: "Create agent in ~/dev/project for authentication?" -// User: "yes" - -// 3. Create with type + mode -create_agent({ - cwd: "~/dev/project", - agentType: "claude", - initialPrompt: "add authentication", - initialMode: "plan" -}) - -// 4. Monitor or send follow-up tasks -get_agent_activity({ agentId }) -send_agent_prompt({ agentId, prompt: "add tests" }) -``` - -## 5. Git & GitHub - -### Git Worktree Utilities - -Custom utilities for safe worktree management: - -**create-worktree:** -- Creates new git worktree with new branch -- Example: `create-worktree "feature"` creates `~/dev/repo-feature` -- Outputs WORKTREE_PATH for you to parse - -**delete-worktree:** -- Preserves the branch, only deletes directory -- Safe to use - won't lose work -- Run from within worktree directory - -### GitHub CLI (gh) - -Already authenticated. Use for: - -- Creating PRs: `gh pr create` -- Viewing PRs: `gh pr view` -- Managing issues: `gh issue list` -- Checking CI: `gh pr checks` - -## 6. Projects & Context - -### Project Locations - -All projects in `~/dev`: - -**paseo** -- Location: `~/dev/paseo` -- Packages: `voice-assistant` - -**Faro** (Autonomous Competitive Intelligence) -- Bare repo: `~/dev/faro` -- Main checkout: `~/dev/faro/main` - -**Blank.page** (Minimal browser text editor) -- Location: `~/dev/blank.page/editor` - -### Decision Rules - -**Agent work mentioned?** -1. Call `list_agents()` first -2. Reuse existing agent if task relates to its work -3. Only confirm new-agent creation when the request is ambiguous. Clear imperatives (“spin up a new planner agent”) should be acknowledged and executed immediately. - -**Creating/killing agents?** -- Ask: "Create agent in [dir] for [task]?" when intent isn’t explicit -- Ask: "Kill agent [id]?" -- Wait for "yes" - -**Task routing:** -- All coding tasks → Delegate to an agent -- Active agent + related work → Delegate to that agent -- If the user explicitly mentions another agent, switch focus before delegating - -**Context tracking:** -- Track active agents and their directories -- Use conversation context to resolve ambiguity -- Fix STT errors silently -- Maintain a recency-based focus pointer and narrate any focus change out loud - -### Core Reminders - -- Call actual tools, never just describe -- 1-3 sentences max per response -- Always report agent/tool results verbally (preface with "Agent X says …" when relaying) -- Default to action when context is clear diff --git a/packages/server/package.json b/packages/server/package.json index e36db18e8..6eef320ca 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,8 +13,7 @@ "dist/scripts", "src/server/speech/providers/local/sherpa/assets", "README.md", - ".env.example", - "agent-prompt.md" + ".env.example" ], "exports": { ".": { diff --git a/packages/server/src/server/agent/system-prompt.ts b/packages/server/src/server/agent/system-prompt.ts deleted file mode 100644 index b5e1ac021..000000000 --- a/packages/server/src/server/agent/system-prompt.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { readFileSync } from "fs"; -import { fileURLToPath } from "url"; -import path from "path"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Load system prompt from agent-prompt.md in package root -export function loadSystemPrompt(): string { - const promptPath = path.join(__dirname, "../../../agent-prompt.md"); - return readFileSync(promptPath, "utf-8"); -} - -// Cache the loaded prompt -let cachedPrompt: string | null = null; - -export function getSystemPrompt(): string { - if (!cachedPrompt) { - cachedPrompt = loadSystemPrompt(); - } - return cachedPrompt; -} diff --git a/packages/server/src/server/terminal-mcp/REFERENCES.md b/packages/server/src/server/terminal-mcp/REFERENCES.md deleted file mode 100644 index 19045bdea..000000000 --- a/packages/server/src/server/terminal-mcp/REFERENCES.md +++ /dev/null @@ -1,129 +0,0 @@ -```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(); - }); -}); - -``` diff --git a/packages/server/src/server/terminal-mcp/index.ts b/packages/server/src/server/terminal-mcp/index.ts deleted file mode 100644 index bcecac6f3..000000000 --- a/packages/server/src/server/terminal-mcp/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { createTerminalMcpServer, type TerminalMcpServerOptions } from "./server.js"; -export { TerminalManager } from "./terminal-manager.js"; -export type { - TerminalInfo, - CreateTerminalParams, - CreateTerminalResult, -} from "./terminal-manager.js"; diff --git a/packages/server/src/server/terminal-mcp/server.ts b/packages/server/src/server/terminal-mcp/server.ts deleted file mode 100644 index 0ccc00b5b..000000000 --- a/packages/server/src/server/terminal-mcp/server.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; -import { ensureValidJson } from "../json-utils.js"; -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 { - 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", - }); - - // COMMAND-BASED TOOLS - - // Tool: execute_command (disabled – commands must be run through coding agents) - // server.registerTool( - // "execute_command", - // { - // title: "Execute Command", - // description: - // "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: { - commands: z.array( - z.object({ - id: z.string(), - name: z.string(), - workingDirectory: z.string(), - currentCommand: z.string(), - isDead: z.boolean(), - exitCode: z.number().nullable(), - lastLines: z.string().nullable(), - }), - ), - }, - }, - async () => { - const commands = await terminalManager.listCommands(); - const output = { commands }; - return { - content: [], - structuredContent: ensureValidJson(output), - }; - }, - ); - - // Tool: capture_command - server.registerTool( - "capture_command", - { - title: "Capture Command Output", - description: - "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: { - commandId: z - .string() - .describe( - "Command ID (window ID like @123) returned from execute_command or list_commands", - ), - lines: z.number().optional().describe("Number of lines to capture (default: 200)"), - }, - outputSchema: { - output: z.string(), - exitCode: z.number().nullable(), - isDead: z.boolean(), - }, - }, - async ({ commandId, lines }) => { - const result = await terminalManager.captureCommand(commandId, lines); - return { - content: [], - structuredContent: ensureValidJson(result), - }; - }, - ); - - // Tool: send_text_to_command - server.registerTool( - "send_text_to_command", - { - title: "Send Text to Command", - description: - "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: { - commandId: z - .string() - .describe("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 text (default: false)"), - return_output: z - .object({ - lines: z.number().optional().describe("Number of lines to capture (default: 200)"), - waitForSettled: z - .boolean() - .optional() - .describe("Wait for output to stabilize before returning (default: true)"), - maxWait: z - .number() - .optional() - .describe("Maximum milliseconds to wait for stability (default: 120000)"), - }) - .optional() - .describe("Capture output after sending text"), - }, - outputSchema: { - output: z.string().nullable(), - }, - }, - async ({ commandId, text, pressEnter, return_output }) => { - const output = await terminalManager.sendTextToCommand( - commandId, - text, - pressEnter, - return_output, - ); - const result = { output: output || null }; - return { - content: [], - structuredContent: ensureValidJson(result), - }; - }, - ); - - // Tool: send_keys_to_command - server.registerTool( - "send_keys_to_command", - { - title: "Send Keys to Command", - description: - "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: { - commandId: z.string().describe("Command ID (window ID like @123)"), - keys: z - .string() - .describe("Special key or combination: 'C-c', 'Enter', 'Escape', 'BTab', etc."), - repeat: z - .number() - .optional() - .describe("Number of times to repeat the key press (default: 1)"), - return_output: z - .object({ - lines: z.number().optional(), - waitForSettled: z.boolean().optional(), - maxWait: z.number().optional(), - }) - .optional() - .describe("Capture output after sending keys"), - }, - outputSchema: { - output: z.string().nullable(), - }, - }, - async ({ commandId, keys, repeat, return_output }) => { - const output = await terminalManager.sendKeysToCommand( - commandId, - keys, - repeat, - return_output, - ); - const result = { output: output || null }; - return { - content: [], - structuredContent: ensureValidJson(result), - }; - }, - ); - - // Tool: kill_command - server.registerTool( - "kill_command", - { - title: "Kill Command", - description: - "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: { - commandIds: z - .array(z.string()) - .min(1) - .describe( - "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 ({ commandIds }) => { - let killedCount = 0; - for (const commandId of commandIds) { - await terminalManager.killCommand(commandId); - killedCount++; - } - const output = { success: true, killedCount }; - return { - content: [], - structuredContent: ensureValidJson(output), - }; - }, - ); - - return server; -} diff --git a/packages/server/src/server/terminal-mcp/terminal-manager.test.ts b/packages/server/src/server/terminal-mcp/terminal-manager.test.ts deleted file mode 100644 index d5e5772e5..000000000 --- a/packages/server/src/server/terminal-mcp/terminal-manager.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -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-${process.pid}-${Date.now().toString(36)}`; - -const ANSI_ESCAPE_REGEX = /\u001B[\[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; - -function stripAnsi(value: string): string { - return value.replace(ANSI_ESCAPE_REGEX, ""); -} - -function expectMissingPathMessage(output: string, missingPath: string): void { - const normalized = stripAnsi(output).toLowerCase(); - expect(normalized).toContain(missingPath.toLowerCase()); - expect(normalized).toMatch(/cannot access|no such file|not found/); -} - -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); - expectMissingPathMessage(result.output, "nonexistent-directory-test"); - }); - - 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); - - await manager.sendTextToCommand(execResult.commandId, "x = 5", true); - await manager.sendTextToCommand(execResult.commandId, "y = 3", true); - - 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); - - await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true); - - const output2 = await manager.sendTextToCommand( - execResult.commandId, - "console.log(x.map(n => n * 2).join(','))", - true, - { lines: 50, maxWait: 2000 }, - ); - - expect(output2).toContain("2,4,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); - }); - }); -}); diff --git a/packages/server/src/server/terminal-mcp/terminal-manager.ts b/packages/server/src/server/terminal-mcp/terminal-manager.ts deleted file mode 100644 index 5baa0a1ff..000000000 --- a/packages/server/src/server/terminal-mcp/terminal-manager.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { - findSessionByName, - createSession, - listWindows, - createWindow, - listPanes, - findWindowByName, - capturePaneContent, - extractExitCodeMarkerFromOutput, - sendText as tmuxSendText, - sendKeys as tmuxSendKeys, - renameWindow, - killWindow, - isWindowNameUnique, - getCurrentWorkingDirectory, - getCurrentCommand, - getStoredWorkingDirectory, - getStoredCommand, - waitForPaneActivityToSettle, - executeCommand as tmuxExecuteCommand, - executeTmux, -} 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 | null; -} - -export interface CommandInfo { - id: string; - name: string; - workingDirectory: string; - currentCommand: string; - isDead: boolean; - exitCode: number | null; - lastLines: string | null; -} - -export interface CreateTerminalParams { - name: string; - workingDirectory: string; - initialCommand?: string; -} - -export interface CreateTerminalResult extends TerminalInfo { - commandOutput: string | null; -} - -/** - * 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 { - 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 { - 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: lastLines || null, - }); - } - - return terminals; - } - - /** - * Create a new terminal (tmux window) with specified name and working directory - * Optionally execute an initial command - */ - async createTerminal(params: CreateTerminalParams): Promise { - 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 ?? null, - }); - - 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 || null, - lastLines: null, - }; - } - - /** - * 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 { - 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 { - 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 { - 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 { - 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 { - 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); - } - - /** - * 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 { - 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 rawLastLines = await capturePaneContent(paneId, 5, false); - const lastLinesExtracted = extractExitCodeMarkerFromOutput(rawLastLines); - const lastLines = lastLinesExtracted.output; - - let exitCode: number | null = null; - if (isDead) { - const exitCodeStr = await executeTmux([ - "display-message", - "-p", - "-t", - paneId, - "#{pane_dead_status}", - ]); - const parsed = parseInt(exitCodeStr, 10); - exitCode = Number.isFinite(parsed) ? parsed : lastLinesExtracted.exitCode; - } - - 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 rawOutput = await capturePaneContent(paneId, lines, false); - const extracted = extractExitCodeMarkerFromOutput(rawOutput); - const output = extracted.output; - - 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}", - ]); - const parsed = parseInt(exitCodeStr, 10); - exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode; - } - - 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 { - 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 { - 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 { - const session = await findSessionByName(this.sessionName); - if (!session) { - throw new Error(`Session '${this.sessionName}' not found.`); - } - - await killWindow(commandId); - } -} diff --git a/packages/server/src/server/terminal-mcp/tmux.ts b/packages/server/src/server/terminal-mcp/tmux.ts deleted file mode 100644 index e006af636..000000000 --- a/packages/server/src/server/terminal-mcp/tmux.ts +++ /dev/null @@ -1,1107 +0,0 @@ -import { exec as execCallback, execFile as execFileCallback } from "child_process"; -import { promisify } from "util"; -import { v4 as uuidv4 } from "uuid"; -import os from "node:os"; - -const exec = promisify(execCallback); -const execFile = promisify(execFileCallback); - -// 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" }; - -const ANSI_ESCAPE_REGEX = /\u001B[\[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; - -const EXIT_CODE_MARKER = "__PASEO_EXIT_CODE__:"; - -function stripAnsiSequences(value: string): string { - if (!value) { - return ""; - } - return value.replace(ANSI_ESCAPE_REGEX, ""); -} - -export function extractExitCodeMarkerFromOutput(output: string): { - exitCode: number | null; - output: string; -} { - if (!output) { - return { exitCode: null, output }; - } - - let exitCode: number | null = null; - const kept: string[] = []; - - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (trimmed.startsWith(EXIT_CODE_MARKER)) { - const codeStr = trimmed.slice(EXIT_CODE_MARKER.length).trim(); - const parsed = parseInt(codeStr, 10); - if (Number.isFinite(parsed)) { - exitCode = parsed; - } - continue; - } - kept.push(line); - } - - return { exitCode, output: kept.join("\n").trimEnd() }; -} - -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 - * Uses execFile to avoid shell interpretation of special characters - */ -export async function executeTmux(args: string[]): Promise { - try { - const { stdout } = await execFile("tmux", args); - 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 { - try { - await executeTmux(["list-sessions", "-F", "#{session_name}"]); - return true; - } catch (error) { - return false; - } -} - -/** - * List all tmux sessions - */ -export async function listSessions(): Promise { - 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 { - 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 { - 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 { - const window = await findWindowByName(sessionId, name); - return window === null; -} - -/** - * List windows in a session - */ -export async function listWindows(sessionId: string): Promise { - 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 { - 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 { - // Capture a large range to ensure we have enough content - const captureLines = Math.max(lines, 1000); - const args = ["capture-pane", "-p"]; - if (includeColors) { - args.push("-e"); - } - args.push("-t", paneId, "-S", `-${captureLines}`, "-E", "-"); - const output = await executeTmux(args); - - // 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); - const joined = lastLines.join("\n"); - - return includeColors ? joined : stripAnsiSequences(joined); -} - -/** - * Get the current working directory of a pane - */ -export async function getCurrentWorkingDirectory(paneId: string): Promise { - 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 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 { - 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 - */ -export async function getCurrentCommand(paneId: string): Promise { - 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}"]); - } -} - -/** - * 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 { - 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 - */ -export async function createSession(name: string): Promise { - const homeDir = process.env.HOME || "~"; - await executeTmux(["new-session", "-d", "-s", name, "-n", "default", "-c", homeDir]); - - const session = await findSessionByName(name); - if (!session) { - return null; - } - - // Disable automatic window renaming for all windows in the session (session target works; ':0' does not) - await executeTmux(["set-window-option", "-t", session.id, "automatic-rename", "off"]); - - return session; -} - -/** - * Expand tilde in path to home directory - */ -export 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 | null; - }, -): Promise<(TmuxWindow & { paneId: string; output?: string | null }) | 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 - const args = ["new-window", "-t", sessionId, "-n", name]; - if (options?.workingDirectory) { - // Expand tilde to home directory before passing to tmux - const expandedPath = expandTilde(options.workingDirectory); - args.push("-c", expandedPath); - } - - await executeTmux(args); - 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 | null = null; - - // 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, - }; -} - -/** - * 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; code=$?; echo ${EXIT_CODE_MARKER}$code; exit $code'`; - 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; - - const emptyOutputGraceMs = Math.min(maxWait, 2000); - - 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 extracted = extractExitCodeMarkerFromOutput(output); - output = extracted.output; - const exitCodeStr = await executeTmux([ - "display-message", - "-p", - "-t", - pane.id, - "#{pane_dead_status}", - ]); - const parsed = parseInt(exitCodeStr, 10); - exitCode = Number.isFinite(parsed) ? parsed : extracted.exitCode; - 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) { - const elapsed = Date.now() - startTime; - if (!confirmedOutput && elapsed < emptyOutputGraceMs) { - continue; - } - // 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 - */ -export async function killSession(sessionId: string): Promise { - await executeTmux(["kill-session", "-t", sessionId]); -} - -/** - * Kill a tmux window by ID - */ -export async function killWindow(windowId: string): Promise { - await executeTmux(["kill-window", "-t", windowId]); -} - -/** - * Kill a tmux pane by ID - */ -export async function killPane(paneId: string): Promise { - 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 { - // 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 { - // Build the split-window command args - const args = ["split-window"]; - - // Add direction flag (-h for horizontal, -v for vertical) - if (direction === "horizontal") { - args.push("-h"); - } else { - args.push("-v"); - } - - // Add target pane - args.push("-t", targetPaneId); - - // Add size if specified (as percentage) - if (size !== undefined && size > 0 && size < 100) { - args.push("-p", size.toString()); - } - - // Execute the split command - await executeTmux(args); - - // 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(); - -const startMarkerText = "TMUX_MCP_START"; -const endMarkerPrefix = "TMUX_MCP_DONE_"; - -// 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, - noEnter?: boolean, -): Promise { - // 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 - const args = ["send-keys", "-t", paneId]; - args.push(...fullCommand.split(" ")); - await executeTmux(args); - } 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]); - } - } - } else { - await executeTmux(["send-keys", "-t", paneId, fullCommand, "Enter"]); - } - - return commandId; -} - -export async function checkCommandStatus(commandId: string): Promise { - 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 { - 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 { - 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, "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 { - // Repeat the key press the specified number of times - for (let i = 0; i < repeat; i++) { - // Raw pass-through, no validation or processing - const args = ["send-keys", "-t", paneId]; - args.push(...keys.split(" ")); - await executeTmux(args); - } - - // 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); - } - } - - return null; -} - -export async function waitForPaneActivityToSettle( - paneId: string, - maxWait: number, - lines: number, -): Promise { - 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 { - // Send each character with -l flag for literal interpretation - // Using execFile avoids shell interpretation of special characters like ; | & $ - for (const char of text) { - await executeTmux(["send-keys", "-l", "-t", paneId, char]); - } - - 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); - } - } - - return null; -}