Remove dead agent-prompt and terminal-mcp code
Delete unused system-prompt.ts, agent-prompt.md, and the entire terminal-mcp/ directory (tmux-based MCP server) — nothing imports any of these. The codebase uses src/terminal/ (node-pty) instead.
This commit is contained in:
@@ -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:
|
||||
|
||||
`<voice-transcription focused-agent-id="...">...</voice-transcription>`
|
||||
|
||||
- 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 <name> 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
|
||||
@@ -13,8 +13,7 @@
|
||||
"dist/scripts",
|
||||
"src/server/speech/providers/local/sherpa/assets",
|
||||
"README.md",
|
||||
".env.example",
|
||||
"agent-prompt.md"
|
||||
".env.example"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
```
|
||||
@@ -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";
|
||||
@@ -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<McpServer> {
|
||||
const { sessionName } = options;
|
||||
const terminalManager = new TerminalManager(sessionName);
|
||||
|
||||
// Initialize the session
|
||||
await terminalManager.initialize();
|
||||
|
||||
const server = new McpServer({
|
||||
name: "terminal-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
await createSession(this.sessionName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all terminals in the session
|
||||
* Returns terminal info including name, active status, working directory, and current command
|
||||
*/
|
||||
async listTerminals(): Promise<TerminalInfo[]> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
const windows = await listWindows(session.id);
|
||||
|
||||
const terminals: TerminalInfo[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
// Get the first (and only) pane in this window
|
||||
const paneId = `${window.id}.0`;
|
||||
|
||||
const workingDirectory = await getCurrentWorkingDirectory(paneId);
|
||||
const currentCommand = await getCurrentCommand(paneId);
|
||||
const lastLines = await capturePaneContent(paneId, 5, false);
|
||||
|
||||
terminals.push({
|
||||
name: window.name,
|
||||
workingDirectory,
|
||||
currentCommand,
|
||||
lastLines: 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<CreateTerminalResult> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found. Call initialize() first.`);
|
||||
}
|
||||
|
||||
// Validate name uniqueness
|
||||
const isUnique = await isWindowNameUnique(session.id, params.name);
|
||||
if (!isUnique) {
|
||||
throw new Error(
|
||||
`Terminal with name '${params.name}' already exists. Please choose a unique name.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Create the window
|
||||
const windowResult = await createWindow(session.id, params.name, {
|
||||
workingDirectory: params.workingDirectory,
|
||||
command: params.initialCommand ?? 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<string> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
// Wait for activity to settle if maxWait is provided
|
||||
if (maxWait) {
|
||||
return waitForPaneActivityToSettle(pane.id, maxWait, lines);
|
||||
}
|
||||
|
||||
return capturePaneContent(pane.id, lines, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send text to a terminal by name, optionally press Enter, optionally return output
|
||||
*/
|
||||
async sendText(
|
||||
terminalName: string,
|
||||
text: string,
|
||||
pressEnter: boolean = false,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendText({
|
||||
paneId: pane.id,
|
||||
text,
|
||||
pressEnter,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send special keys or key combinations to a terminal by name
|
||||
* Useful for TUI navigation, control sequences, and interactive applications
|
||||
*/
|
||||
async sendKeys(
|
||||
terminalName: string,
|
||||
keys: string,
|
||||
repeat: number = 1,
|
||||
return_output?: { lines?: number; waitForSettled?: boolean; maxWait?: number },
|
||||
): Promise<string | null> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first pane
|
||||
const panes = await listPanes(window.id);
|
||||
const pane = panes[0];
|
||||
if (!pane) {
|
||||
throw new Error(`No pane found for terminal ${terminalName}`);
|
||||
}
|
||||
|
||||
return tmuxSendKeys({
|
||||
paneId: pane.id,
|
||||
keys,
|
||||
repeat,
|
||||
return_output,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a terminal by name
|
||||
* Validates that the new name is unique
|
||||
*/
|
||||
async renameTerminal(terminalName: string, newName: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// renameWindow handles uniqueness validation and name resolution internally
|
||||
await renameWindow(session.id, terminalName, newName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill (close/destroy) a terminal by name
|
||||
*/
|
||||
async killTerminal(terminalName: string): Promise<void> {
|
||||
const session = await findSessionByName(this.sessionName);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session '${this.sessionName}' not found.`);
|
||||
}
|
||||
|
||||
// Resolve terminal name to window
|
||||
const window = await findWindowByName(session.id, terminalName);
|
||||
if (!window) {
|
||||
const windows = await listWindows(session.id);
|
||||
const availableNames = windows.map((w) => w.name).join(", ");
|
||||
throw new Error(
|
||||
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`,
|
||||
);
|
||||
}
|
||||
|
||||
await killWindow(window.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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<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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user