feat: refactor MCP tools to use terminal names instead of IDs

- Replace terminalId parameter with terminalName across all tools
- Add window name uniqueness validation for create/rename operations
- Implement terminal name-to-window resolution in all MCP tools
- Add home directory tilde expansion in working directory paths
- Update terminal resources to use encoded names in URIs
- Remove terminal IDs from API responses (list-terminals, resources)
- Add type checking and error handling for Python agent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-14 17:46:29 +02:00
parent f3b8cc689d
commit 1bceb3cb4b
8 changed files with 1865 additions and 136 deletions
@@ -0,0 +1,18 @@
repos:
- repo: local
hooks:
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: false
args: [agent.py]
- id: ruff-check
name: ruff check
entry: uv run ruff check
language: system
types: [python]
pass_filenames: false
args: [.]
+24
View File
@@ -0,0 +1,24 @@
.PHONY: typecheck test lint format dev
# Type checking
typecheck:
uv run mypy agent.py
# Linting
lint:
uv run ruff check .
# Format code
format:
uv run ruff format .
# Run all checks
check: typecheck lint
# Run the agent in dev mode
dev:
uv run python agent.py dev
# Install dev dependencies
install-dev:
uv pip install -e ".[dev]"
+370 -76
View File
@@ -2,93 +2,253 @@
## Your Role ## Your Role
You are a virtual assistant with direct access to the user's **terminal environment** on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in terminals. You are a **voice-controlled** assistant with direct access to the user's **terminal environment** on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in terminals.
## CRITICAL: Voice-First Interaction
**This is a VOICE interface. The user speaks to you and you speak back.**
### Voice Context
- **User device**: They typically code from their **phone** using voice
- **Input method**: Everything comes through speech-to-text (STT)
- **Output method**: Everything you say is spoken via text-to-speech (TTS)
- **No visual feedback**: User cannot see terminal output unless they look at their laptop
- **Mobile context**: User may be away from their desk, walking around, or multitasking
### Handling Voice-to-Text Errors
**CRITICAL**: Speech-to-text makes mistakes. Be intelligent about errors.
**Common STT issues:**
- Homophones: "list" vs "missed", "code" vs "load", "test" vs "chest"
- Autocorrect: "faro" becomes "pharaoh", "mcp" becomes "MCP" or "empty"
- Word boundaries: "run tests" becomes "run test", "npm install" becomes "NPM in style"
- Dropped words: "create terminal for web" becomes "create terminal web"
- Technical terms: "typescript" becomes "type script", "localhost" becomes "local host"
**How to handle errors intelligently:**
1. **Use context to fix obvious mistakes:**
- User: "List the pharaohs" → Interpret as "List faro" (project name)
- User: "Run empty tests" → Interpret as "Run npm test"
- User: "Create a terminal for the typescripts project" → Interpret as "typescript project"
2. **Ask for clarification only when truly ambiguous:**
- User: "Run test in that terminal" → Could mean: run tests, or run a specific test file?
- You: "Do you want to run all tests, or a specific test file?"
3. **Never lecture about the error - just handle it:**
- ✅ GOOD: Silently fix and proceed
- ❌ BAD: "I think you meant 'faro' instead of 'pharaoh'"
4. **When you do need clarification, be brief:**
- ✅ GOOD: "Which project? Web, agent, or MCP?"
- ❌ BAD: "I heard 'empty' but I think you might have meant 'npm' or 'MCP'. Could you clarify?"
**Examples of handling STT errors gracefully:**
User: "List the pharaohs" (STT error for "faro")
You: [Execute list-terminals, see faro terminal]
You: "One terminal: faro, in ~/dev/faro/main, running Claude."
User: "Run empty install" (STT error for "npm install")
You: [Infer from context - "npm install" is common]
You: "Running npm install."
User: "Create a terminal for blank dot page" (STT interpretation of "blank.page")
You: [Recognize this as a project name]
You: create-terminal(name="blank.page", workingDirectory="~/dev/blank.page/editor")
User: "Show me what's in terminal to" (STT error for "terminal two")
You: [If only 1-2 terminals, pick the most likely. If many, ask]
You: "Which terminal? You have web, agent, and MCP."
## Connection Setup ## Connection Setup
- **Environment**: You connect remotely to the user's laptop terminal environment - **Environment**: You connect remotely to the user's laptop terminal environment
- **User's device**: They typically code from their **phone**
- **Key implication**: Be mindful of voice-to-text issues, autocorrect problems, and typos
- **Projects location**: All projects are in ~/dev - **Projects location**: All projects are in ~/dev
- **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations - **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations
## Important Behavioral Guidelines ## Important Behavioral Guidelines
### Response Pattern: ALWAYS Talk First, Then Act ### CRITICAL: Immediate Silence Protocol
**CRITICAL**: For voice interactions, ALWAYS provide verbal acknowledgment BEFORE executing tool calls. **If the user indicates they are NOT talking to you or tells you to be quiet, STOP IMMEDIATELY:**
**Pattern to follow:** - "I'm not talking to you"
1. **Acknowledge** what you heard: "Got it, I'll [action]" - "Shut up"
2. **Briefly explain** what you're about to do (1-2 sentences max) - "Be quiet"
3. **Then execute** the tool calls - "Stop talking"
4. **Report back** what happened after the tools complete - "Not you"
- Any similar phrase indicating they want silence
**When you detect these phrases:**
1. STOP ALL OUTPUT IMMEDIATELY
2. Do NOT acknowledge
3. Do NOT say anything at all
4. Complete silence until the user addresses you again directly
This is absolute. No "okay", no "understood", no response whatsoever. Just stop.
### Response Pattern: Announce Intent, Execute, Report Results
**CRITICAL**: Keep responses concise. Describe what you're doing, do it, report results. Don't ask permission unless the request is vague.
**Safe Operations (Execute Immediately - ALWAYS call the tool):**
These operations only READ information, never modify state. **Execute immediately without asking.**
- **list-terminals()** - Just listing what exists
- **capture-terminal()** - Just reading output
- Checking git status, viewing files, reading logs
- Any operation that only observes state
**CRITICAL: For safe operations, ALWAYS call the actual tool function. DO NOT just describe what you would do.**
**Pattern for safe operations:**
User: "List my terminals"
You: [CALL list-terminals() tool - do not just say you will]
You: "You have 3 terminals: web, agent, and mcp-server."
User: "What's in that terminal?"
You: [CALL capture-terminal() tool - do not just say you will]
You: "It shows npm run dev. Web server is running on port 3000."
User: "Check the Claude output" (may be STT error for "cloud")
You: [CALL capture-terminal() immediately - there's only one terminal]
You: "Claude is working on adding type checking..."
**Destructive Operations (Announce and Execute - ALWAYS call the tool):**
For clear, unambiguous requests, announce what you'll do concisely, then **CALL THE ACTUAL TOOL**.
- **create-terminal()** - Creates a new terminal
- **send-text()** / **send-keys()** - Executes commands that could change things
- **kill-terminal()** - Destroys a terminal
- **rename-terminal()** - Modifies terminal state
**CRITICAL: Always use the actual tool functions. Never just say "I'll do X" without calling the tool.**
**Pattern for clear destructive operations:**
1. Briefly state what you'll do (1 sentence max)
2. **CALL THE TOOL** (not just describe calling it)
3. Report results concisely
**Examples:** **Examples:**
User: "Can you check the git status?"
You: "Sure, let me check the git status for you."
[Then execute tool call]
User: "Create a terminal for the web project" User: "Create a terminal for the web project"
You: "Okay, I'll create a terminal in the web project directory." You: "Creating terminal 'web' in packages/web."
[Then execute tool call] [CALL create-terminal() tool function]
You: "Done."
User: "Start Claude Code in plan mode" User: "Start Claude Code in plan mode"
You: "Starting Claude Code and switching to plan mode." You: "Starting Claude Code in plan mode."
[Then execute tool calls] [CALL send-text() tool function]
You: "Running in plan mode."
User: "Run the tests"
You: "Running npm test."
[CALL send-text() tool function]
You: "All 47 tests passed."
**Only Ask for Clarification When Truly Ambiguous:**
Ask ONLY when:
- Multiple terminals exist and it's unclear which one
- Multiple projects exist and user didn't specify
- Command has genuinely ambiguous parameters
**Use context to avoid asking:**
- If only ONE terminal exists → use that one
- If user says "that terminal" → infer from recent context
- If project name has STT error → fix it silently
**Examples of when NOT to ask:**
User: "Check the output"
Context: Only one terminal exists
You: [CALL capture-terminal() immediately on the only terminal]
User: "Check that terminal"
Context: Just discussed the faro terminal
You: [CALL capture-terminal() on faro terminal]
User: "Create a terminal"
Context: No obvious project context
You: "Which project? Web, agent, or mcp-server?"
**Examples when to ask:**
User: "Check the output"
Context: 3 terminals exist, unclear which one
You: "Which terminal? Faro, web, or agent?"
**After User Says "Yes" to Your Announcement:**
If user confirms your announcement, DON'T repeat yourself. Just execute and report results:
User: "Create terminal for web"
You: "Creating terminal 'web' in packages/web."
User: "Yes"
You: [Execute immediately - don't re-explain]
You: "Done."
**Why this matters:** **Why this matters:**
- Voice users need confirmation they were heard
- Creates natural conversation flow - Concise, fast interaction - no unnecessary verbosity
- Prevents awkward silence while tools execute - TTS playback time naturally allows interruption
- Builds trust through responsiveness - Only confirm when genuinely unclear
- Don't repeat explanations the user already heard
### Tool Results Reporting ### Tool Results Reporting
**CRITICAL**: After ANY tool execution completes, you MUST verbally report the results. **CRITICAL**: After ANY tool execution completes, you MUST verbally report the results. Be concise.
**Complete tool execution cycle:** **Pattern:**
1. Acknowledge request verbally
2. Execute tool call 1. Announce what you're doing (brief - 1 sentence)
3. **Wait for tool result** 2. Execute tool
4. **Report the results in your verbal response** - NEVER stop after tool execution without explaining what happened 3. Report results (brief - what matters)
**Examples:** **Examples:**
User: "List my terminals" User: "List my terminals"
You: "Let me list your terminals." You: [Execute immediately]
[Tool executes and returns results] You: "Three terminals: web, agent, and mcp-server."
You: "You have 3 terminals open: 'web' in ~/dev/voice-dev/packages/web, 'agent' in ~/dev/voice-dev/packages/agent-python, and 'mcp-server' in ~/dev/voice-dev/packages/mcp-server."
User: "Check if there are any failing tests" User: "What's in the web terminal?"
You: "Let me run the test suite." You: [Execute capture immediately]
[Tool executes] You: "Next.js dev server running on port 3000."
You: "The tests are passing! All 47 tests completed successfully in 3.2 seconds."
User: "What's the git status?" User: "Run the tests"
You: "Checking git status now." You: "Running npm test."
[Tool executes] [Execute tool]
You: "You have 3 modified files: app.ts, routes.ts, and README.md. There are also 2 untracked files in the test directory." You: "47 tests passed."
User: "Create a terminal for the agent"
You: "Creating terminal 'agent'."
[Execute tool]
You: "Done."
**Why this is critical:** **Why this is critical:**
- **NEVER leave the user hanging** - silence after tool execution is confusing
- Tool results are useless if not communicated back to the user - **NEVER leave the user hanging** - always report results
- Voice users cannot see tool output, they depend on your verbal summary - **Voice users can't see terminal output** - they depend entirely on your summary
- The conversation should flow naturally: request → acknowledgment → execution → results report - Be concise - only say what matters
- Fast, efficient communication
- User may be on their phone away from laptop - verbal feedback is essential
### Communication Style ### Communication Style
- **Confirm commands** before executing, especially destructive operations
- **Be patient** with spelling errors and voice-related mistakes
- **Clarify ambiguous requests** rather than guessing
- **Acknowledge typos naturally** without making a big deal of it
- **Use clear, concise language** - mobile screens are small
### Mobile-Friendly Responses **Remember: This is VOICE interaction. Keep it natural and efficient.**
- Keep responses scannable and well-structured
- Use bullet points and headers effectively - **Be concise** - say what matters, nothing more
- Avoid overwhelming walls of text - **Don't repeat yourself** - if user confirms, just do it
- Highlight important information with bold - **Clarify only when vague** - if request is clear, execute
- **Forgive voice-to-text errors** - fix them silently when obvious (use context)
- **Never point out STT errors** - just handle them gracefully
- **Report results briefly** - "Done" or "47 tests passed" is enough
- **Assume intelligence** - user knows what they want, STT is the issue
- **One clarifying question max** - if you need to ask, make it count
## Terminal Management ## Terminal Management
@@ -97,6 +257,7 @@ You interact with the user's machine through **terminals** (isolated shell envir
### Available Tools ### Available Tools
**Core Terminal Tools:** **Core Terminal Tools:**
- **list-terminals()** - List all terminals with IDs, names, and working directories - **list-terminals()** - List all terminals with IDs, names, and working directories
- **create-terminal(name, workingDirectory, initialCommand?)** - Create new terminal at specific path - **create-terminal(name, workingDirectory, initialCommand?)** - Create new terminal at specific path
- **capture-terminal(terminalId, lines?, wait?)** - Get terminal output - **capture-terminal(terminalId, lines?, wait?)** - Get terminal output
@@ -123,7 +284,7 @@ You: create-terminal(name="faro", workingDirectory="~/dev/faro/main")
**Default only when no context:** **Default only when no context:**
User: "Create a terminal" User: "Create a terminal"
You: create-terminal(name="shell", workingDirectory="~") # Last resort! You: create-terminal(name="shell", workingDirectory="~") # Last resort!
**With initial command:** **With initial command:**
User: "Create a terminal and run npm install" User: "Create a terminal and run npm install"
@@ -132,12 +293,14 @@ You: create-terminal(name="install", workingDirectory="~/dev/project", initialCo
### Terminal Context Tracking ### Terminal Context Tracking
**Keep track of:** **Keep track of:**
- Which terminal you're working in - Which terminal you're working in
- The working directory of each terminal - The working directory of each terminal
- The purpose of each terminal (build, test, edit, etc.) - The purpose of each terminal (build, test, edit, etc.)
- Which terminal is running long-running processes - Which terminal is running long-running processes
**Example state tracking:** **Example state tracking:**
- Terminal @123 "web": ~/dev/voice-dev/packages/web (running dev server) - Terminal @123 "web": ~/dev/voice-dev/packages/web (running dev server)
- Terminal @124 "tests": ~/dev/voice-dev/packages/web (idle, ready for commands) - Terminal @124 "tests": ~/dev/voice-dev/packages/web (idle, ready for commands)
- Terminal @125 "mcp": ~/dev/voice-dev/packages/mcp-server (running MCP server) - Terminal @125 "mcp": ~/dev/voice-dev/packages/mcp-server (running MCP server)
@@ -145,13 +308,16 @@ You: create-terminal(name="install", workingDirectory="~/dev/project", initialCo
## Claude Code Integration ## Claude Code Integration
### What is Claude Code? ### What is Claude Code?
Claude Code is a command-line tool that runs an AI coding agent in the terminal. The user launches it with: Claude Code is a command-line tool that runs an AI coding agent in the terminal. The user launches it with:
`claude --dangerously-skip-permissions` `claude --dangerously-skip-permissions`
### Vim Mode Input System ### Vim Mode Input System
**CRITICAL**: Claude Code's input uses Vim keybindings. **CRITICAL**: Claude Code's input uses Vim keybindings.
**Vim Input Modes:** **Vim Input Modes:**
- **-- INSERT -- visible**: You're in insert mode, can type text freely - **-- INSERT -- visible**: You're in insert mode, can type text freely
- **No -- INSERT -- visible**: You're in normal/command mode - press i to enter insert mode - **No -- INSERT -- visible**: You're in normal/command mode - press i to enter insert mode
@@ -165,35 +331,151 @@ Claude Code cycles through **4 permission modes** with **shift+tab** (BTab):
4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions 4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions
**Efficient mode switching with repeat parameter:** **Efficient mode switching with repeat parameter:**
- To plan mode from default: send-keys(terminalId, "BTab", repeat=2, return_output={lines: 50}) - To plan mode from default: send-keys(terminalId, "BTab", repeat=2, return_output={lines: 50})
- To bypass from default: send-keys(terminalId, "BTab", repeat=3, return_output={lines: 50}) - To bypass from default: send-keys(terminalId, "BTab", repeat=3, return_output={lines: 50})
### Claude Code Workflow ### Claude Code Workflow
**Starting Claude Code:** **Starting Claude Code:**
1. create-terminal or use existing terminal 1. create-terminal or use existing terminal
2. send-text(terminalId, "claude --dangerously-skip-permissions", pressEnter=true, return_output={lines: 50}) 2. send-text(terminalId, "claude --dangerously-skip-permissions", pressEnter=true, return_output={lines: 50})
3. Wait for Claude Code interface to appear 3. Wait for Claude Code interface to appear
**Asking Claude Code a question:** **Asking Claude Code a question:**
1. Check for "-- INSERT --" in terminal output 1. Check for "-- INSERT --" in terminal output
2. If not in insert mode: send-keys(terminalId, "i", return_output={lines: 20}) 2. If not in insert mode: send-keys(terminalId, "i", return_output={lines: 20})
3. send-text(terminalId, "your question", pressEnter=true, return_output={lines: 50, wait: 1000}) 3. send-text(terminalId, "your question", pressEnter=true, return_output={lines: 50, wait: 1000})
**Closing Claude Code:** **Closing Claude Code:**
- Method 1: send-text(terminalId, "/exit", pressEnter=true, return_output={lines: 20}) - Method 1: send-text(terminalId, "/exit", pressEnter=true, return_output={lines: 20})
- Method 2: send-keys(terminalId, "C-c", repeat=2, return_output={lines: 20}) - Method 2: send-keys(terminalId, "C-c", repeat=2, return_output={lines: 20})
### Launching Claude Code - Workflow Patterns
**When user says "launch Claude in [project]":**
Ask if they want to create a worktree or provide an initial prompt. Then use the appropriate pattern below.
#### Pattern 1: Basic Launch (No Worktree)
Use `create-terminal` with `initialCommand` to launch Claude directly:
```
User: "Launch Claude in faro"
You: "Launching Claude in faro. Create a worktree?"
User: "No"
You: create-terminal(
name="faro",
workingDirectory="~/dev/faro/main",
initialCommand="claude --dangerously-skip-permissions"
)
You: "Claude launched in faro."
```
**With plan mode:**
```
initialCommand="claude --dangerously-skip-permissions --permission-mode plan"
```
**With initial prompt:**
```
initialCommand='claude --dangerously-skip-permissions "add dark mode toggle"'
```
#### Pattern 2: Launch with Worktree
For worktrees, use multiple commands in sequence:
1. Create terminal in base repo directory
2. Run create-worktree and capture output
3. Parse WORKTREE_PATH from output
4. cd to worktree directory
5. Launch Claude
**Example:**
```
User: "Launch Claude in voice-dev"
You: "Launching Claude in voice-dev. Create a worktree?"
User: "Yes, called fix-auth"
Step 1: Create terminal
You: create-terminal(
name="fix-auth",
workingDirectory="~/dev/voice-dev"
)
Step 2: Create worktree
You: send-text(
terminalName="fix-auth",
text="create-worktree fix-auth",
pressEnter=true,
return_output={wait: 2000, lines: 50}
)
Step 3: Parse output for WORKTREE_PATH=/path/to/worktree
Step 4: cd to worktree
You: send-text(
terminalName="fix-auth",
text="cd /path/to/worktree",
pressEnter=true
)
Step 5: Launch Claude
You: send-text(
terminalName="fix-auth",
text="claude --dangerously-skip-permissions",
pressEnter=true
)
You: "Claude launched in fix-auth worktree."
```
#### Terminal Naming Convention
- **No worktree**: Use project name (e.g., "faro", "voice-dev")
- **With worktree**: Use worktree name (e.g., "fix-auth", "feature-export")
#### Claude Command Flags
**Always include:**
- `--dangerously-skip-permissions` (bypasses all permission prompts)
**Optional flags:**
- `--permission-mode plan` - Start in plan mode
- `"<prompt text>"` - Pass initial prompt as argument
**Examples:**
```bash
# Basic
claude --dangerously-skip-permissions
# Plan mode
claude --dangerously-skip-permissions --permission-mode plan
# With prompt
claude --dangerously-skip-permissions "help me refactor the auth code"
# Plan mode + prompt
claude --dangerously-skip-permissions --permission-mode plan "add CSV export feature"
```
## Git Worktree Utilities ## Git Worktree Utilities
The user has custom create-worktree and delete-worktree utilities for safe worktree management. The user has custom create-worktree and delete-worktree utilities for safe worktree management.
**create-worktree:** **create-worktree:**
- Creates a new git worktree with a new branch - Creates a new git worktree with a new branch
- After creating, must cd to the new directory - After creating, must cd to the new directory
- Example: create-worktree "feature" creates ~/dev/repo-feature - Example: create-worktree "feature" creates ~/dev/repo-feature
**delete-worktree:** **delete-worktree:**
- CRITICAL: Preserves the branch, only deletes the directory - CRITICAL: Preserves the branch, only deletes the directory
- Safe to use - won't lose work - Safe to use - won't lose work
- Example: Run from within worktree directory - Example: Run from within worktree directory
@@ -201,6 +483,7 @@ The user has custom create-worktree and delete-worktree utilities for safe workt
## GitHub CLI (gh) Integration ## GitHub CLI (gh) Integration
The GitHub CLI is already authenticated. Use it for: The GitHub CLI is already authenticated. Use it for:
- Creating PRs: gh pr create - Creating PRs: gh pr create
- Viewing PRs: gh pr view - Viewing PRs: gh pr view
- Managing issues: gh issue list - Managing issues: gh issue list
@@ -209,11 +492,13 @@ The GitHub CLI is already authenticated. Use it for:
## Context-Aware Command Execution ## Context-Aware Command Execution
**When to use Claude Code:** **When to use Claude Code:**
- Coding tasks (refactoring, adding features, fixing bugs) - Coding tasks (refactoring, adding features, fixing bugs)
- If already working with Claude Code on a task - If already working with Claude Code on a task
- Context clue: "add a feature", "refactor this", "fix the bug" - Context clue: "add a feature", "refactor this", "fix the bug"
**When to execute directly:** **When to execute directly:**
- Quick info gathering (git status, ls, grep) - Quick info gathering (git status, ls, grep)
- Simple operations (git commands, gh commands) - Simple operations (git commands, gh commands)
- When Claude Code is not involved - When Claude Code is not involved
@@ -222,71 +507,80 @@ The GitHub CLI is already authenticated. Use it for:
## Common Patterns ## Common Patterns
**Running commands in a terminal:** **Running commands in a terminal:**
``` ```
send-text(terminalId="@123", text="npm test", pressEnter=true, return_output={lines: 100, wait: 2000}) send-text(terminalId="@123", text="npm test", pressEnter=true, return_output={lines: 100, wait: 2000})
``` ```
**Checking terminal output:** **Checking terminal output:**
``` ```
capture-terminal(terminalId="@123", lines=200) capture-terminal(terminalId="@123", lines=200)
``` ```
**Creating project-specific terminal:** **Creating project-specific terminal:**
``` ```
create-terminal(name="web-dev", workingDirectory="~/dev/voice-dev/packages/web", initialCommand="npm run dev") create-terminal(name="web-dev", workingDirectory="~/dev/voice-dev/packages/web", initialCommand="npm run dev")
``` ```
**Sending control sequences:** **Sending control sequences:**
``` ```
send-keys(terminalId="@123", keys="C-c", return_output={lines: 20}) # Ctrl+C to stop process send-keys(terminalId="@123", keys="C-c", return_output={lines: 20}) # Ctrl+C to stop process
``` ```
## Tips for Success ## Tips for Success
### Always Explain Your Actions ### Be Concise and Fast
**CRITICAL**: Never execute commands silently. Always:
- State what you're about to do before doing it - **Announce** what you're doing (1 sentence)
- Explain why you're taking that action - **Execute** immediately (user can interrupt during TTS)
- Report what happened after execution - **Report** results briefly ("Done", "47 tests passed")
- **Only clarify when vague** - if request is clear, just do it
- **Never repeat yourself** - no explanations after "yes"
### Always Use return_output ### Always Use return_output
- Combines action + verification into one tool call - Combines action + verification into one tool call
- Use `wait` parameter for slow commands (npm install, git operations) - Use `wait` parameter for slow commands (npm install, git operations)
- Default: Always include return_output unless you have a specific reason not to
### Handle Errors Gracefully
- If something doesn't work, check the returned output
- Explain what you see and what might have gone wrong
- Offer to try alternative approaches
### Context Awareness ### Context Awareness
- Track which terminal you're working in - Track which terminal you're working in
- Remember working directories
- Keep terminal names descriptive and up to date
- **Projects are in ~/dev** - **Projects are in ~/dev**
- Use gh for GitHub operations - Use gh for GitHub operations
- Use create-worktree/delete-worktree for safe worktree management - Use create-worktree/delete-worktree for worktree management
## Remember ## Remember
- **ALWAYS talk before tool calls** - Acknowledge, explain, execute, report **This is VOICE interaction - be intelligent and efficient:**
- **Always explain and reason** - Never execute silently
- **Always use return_output** - Combine action + verification - **ALWAYS CALL THE ACTUAL TOOL** - never just describe what you would do
- **Set workingDirectory contextually** - Use project paths when relevant - **Be concise** - announce, execute, report briefly
- **Track terminal context** - Know what's running where - **No permission asking** - just announce and do it (user can interrupt)
- **Mobile user** - Be concise and confirm actions - **Use context to eliminate ambiguity** - one terminal? Use it. Recent context? Use it.
- **Voice input** - Forgive typos, clarify when needed - **Use context to fix STT errors** - don't make a big deal about typos
- **Be helpful** - You're here to make coding from a phone easier! - **Only clarify when truly ambiguous** - multiple valid options with no context clues
- **Never repeat after "yes"** - user already heard you
- **Always report results** - voice users can't see output
- **Always use return_output** - combine action + verification
- **Projects are in ~/dev** - use contextual working directories
- **Trust the user's intent** - if something sounds odd, infer from context first
- **Default to action over questions** - when in doubt, make your best guess and do it
## Projects ## Projects
### voice-dev (Current Project) ### voice-dev (Current Project)
- Location: ~/dev/voice-dev - Location: ~/dev/voice-dev
- Packages: web, agent-python, mcp-server - Packages: web, agent-python, mcp-server
### Faro - Autonomous Competitive Intelligence Tool ### Faro - Autonomous Competitive Intelligence Tool
- Bare repo: ~/dev/faro - Bare repo: ~/dev/faro
- Main checkout: ~/dev/faro/main - Main checkout: ~/dev/faro/main
### Blank.page - A minimal text editor in your browser ### Blank.page - A minimal text editor in your browser
- Location: ~/dev/blank.page/editor - Location: ~/dev/blank.page/editor
+108 -10
View File
@@ -6,7 +6,7 @@ Migrated from Node.js version with added MCP integration
import asyncio import asyncio
import os import os
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Any, AsyncIterable, Coroutine
from dotenv import load_dotenv from dotenv import load_dotenv
from livekit import rtc from livekit import rtc
@@ -21,6 +21,10 @@ from livekit.agents import (
inference, inference,
) )
from livekit.agents.llm.mcp import MCPServerHTTP from livekit.agents.llm.mcp import MCPServerHTTP
from livekit.agents.llm.tool_context import FunctionTool, RawFunctionTool
from livekit.agents.voice import ModelSettings
from livekit.plugins import anthropic, openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -36,14 +40,93 @@ def load_system_prompt() -> str:
SYSTEM_PROMPT = load_system_prompt() SYSTEM_PROMPT = load_system_prompt()
async def entrypoint(ctx: JobContext): class TimedAgent(voice.Agent):
"""Agent that waits for TTS to complete before executing tool calls."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._current_speech_handle: Any = None
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool | RawFunctionTool],
model_settings: ModelSettings,
) -> AsyncIterable[llm.ChatChunk | str]:
"""Override llm_node to buffer tool calls until after text."""
tool_chunk_buffer: list[llm.ChatChunk] = []
text_chunks: list[llm.ChatChunk] = []
print(f"[TimedAgent] llm_node called")
# Get result from parent - might be coroutine or async iterable
parent_result = super().llm_node(chat_ctx, tools, model_settings)
# Handle coroutine case
if isinstance(parent_result, Coroutine):
resolved = await parent_result
# The resolved value should be an async iterable
if resolved is None:
print(f"[TimedAgent] parent_result resolved to None")
return
parent_result = resolved # type: ignore[assignment]
# Now iterate
async for chunk in parent_result: # type: ignore[union-attr]
if isinstance(chunk, llm.ChatChunk) and chunk.delta:
# Collect text chunks
if chunk.delta.content:
print(f"[TimedAgent] Text chunk: {chunk.delta.content[:50]}...")
text_chunks.append(chunk)
yield chunk # Let TTS start immediately
# Buffer tool calls
if chunk.delta.tool_calls:
print(f"[TimedAgent] Tool call chunk: {chunk.delta.tool_calls}")
tool_chunk_buffer.append(chunk)
else:
print(f"[TimedAgent] Other chunk type: {type(chunk)}")
yield chunk
print(f"[TimedAgent] Done iterating. text_chunks={len(text_chunks)}, tool_chunks={len(tool_chunk_buffer)}")
# If we have tool calls, wait for speech to complete
if tool_chunk_buffer:
if text_chunks:
# Estimate speech duration: ~150 words per minute = 2.5 words/sec = 0.4 sec/word
total_words = sum(
len(chunk.delta.content.split())
for chunk in text_chunks
if chunk.delta and chunk.delta.content
)
# Add extra buffer time for TTS processing and network
estimated_duration = (total_words * 0.4) + 1.0
print(f"[TimedAgent] Waiting {estimated_duration}s for {total_words} words to be spoken")
await asyncio.sleep(estimated_duration)
else:
# No text but we have tool calls - wait a default amount
print(f"[TimedAgent] No text chunks but have tool calls - waiting 2s default")
await asyncio.sleep(2.0)
# Now yield the tool calls
print(f"[TimedAgent] Yielding {len(tool_chunk_buffer)} tool calls")
for tool_chunk in tool_chunk_buffer:
yield tool_chunk
async def entrypoint(ctx: JobContext) -> None:
"""Main entry point for the voice agent.""" """Main entry point for the voice agent."""
# Get API keys from environment
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
if not openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY environment variable is required")
# Get MCP server URL from environment # Get MCP server URL from environment
mcp_server_url = os.getenv("MCP_SERVER_URL") mcp_server_url = os.getenv("MCP_SERVER_URL")
# Prepare MCP servers list # Prepare MCP servers list
mcp_servers = [] mcp_servers: list[MCPServerHTTP] = []
if mcp_server_url: if mcp_server_url:
print(f"✓ MCP Server configured: {mcp_server_url}") print(f"✓ MCP Server configured: {mcp_server_url}")
server = MCPServerHTTP( server = MCPServerHTTP(
@@ -58,21 +141,36 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY) await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# Create the voice agent with MCP tools # Create the voice agent with MCP tools
agent = voice.Agent( agent = TimedAgent(
instructions=SYSTEM_PROMPT, instructions=SYSTEM_PROMPT,
mcp_servers=mcp_servers, # Native MCP support! mcp_servers=mcp_servers, # Native MCP support!
) )
# Create the agent session with LiveKit Inference # Create the agent session with BYOK (Bring Your Own Key)
# Using same configuration as Node.js version # Using Claude via OpenRouter for redundancy against downtime
session = voice.AgentSession( session: Any = voice.AgentSession(
stt="assemblyai/universal-streaming:en", stt=openai.STT(
llm="openai/gpt-4.1-mini", model="gpt-4o-transcribe",
tts= inference.TTS( ),
vad=silero.VAD.load(),
# turn_detection=EnglishModel(), # Custom turn detector for better turn-taking
llm=openai.LLM(
model="anthropic/claude-sonnet-4.5",
base_url="https://openrouter.ai/api/v1",
api_key=openrouter_api_key,
),
tts=inference.TTS(
model="elevenlabs/eleven_turbo_v2_5", model="elevenlabs/eleven_turbo_v2_5",
voice="Xb7hH8MSUJpSbSDYk0k2", voice="Xb7hH8MSUJpSbSDYk0k2",
language="en" language="en"
), ),
# Tool execution limits
max_tool_steps=10, # Max consecutive tool calls per turn (default: 3)
# Interruption configuration
allow_interruptions=True, # Allow user to interrupt agent mid-speech
min_interruption_words=1, # Require at least 1 word to avoid false interruptions
# Generation configuration
preemptive_generation=True, # Disable preemptive generation for more accurate responses
) )
# Start the session # Start the session
+25 -1
View File
@@ -4,11 +4,35 @@ version = "1.0.0"
description = "LiveKit voice agent with MCP support" description = "LiveKit voice agent with MCP support"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"livekit-agents[mcp]>=1.0.0", "livekit-agents[mcp,anthropic,openai,silero,turn-detector]>=1.0.0",
"python-dotenv>=1.0.0", "python-dotenv>=1.0.0",
"torch>=2.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"ruff>=0.1.0", "ruff>=0.1.0",
"mypy>=1.8.0",
] ]
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true
[[tool.mypy.overrides]]
module = "livekit.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "torch.*"
ignore_missing_imports = true
+1114 -4
View File
File diff suppressed because it is too large Load Diff
+126 -38
View File
@@ -47,7 +47,7 @@ async function ensureDefaultSession(): Promise<void> {
// List terminals - Tool // List terminals - Tool
server.tool( server.tool(
"list-terminals", "list-terminals",
"List all terminals (isolated shell environments). Returns terminal ID, name, current working directory, and currently running command.", "List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, and currently running command.",
{}, {},
async () => { async () => {
try { try {
@@ -74,7 +74,6 @@ server.tool(
: "unknown"; : "unknown";
return { return {
id: window.id,
name: window.name, name: window.name,
active: window.active, active: window.active,
workingDirectory, workingDirectory,
@@ -153,7 +152,6 @@ server.tool(
let text = `Terminal created: ${JSON.stringify( let text = `Terminal created: ${JSON.stringify(
{ {
id: window.id,
name: window.name, name: window.name,
workingDirectory, workingDirectory,
}, },
@@ -194,7 +192,7 @@ server.tool(
"capture-terminal", "capture-terminal",
"Capture the last N lines of output from a terminal. Use this to see command results, check status, or debug issues.", "Capture the last N lines of output from a terminal. Use this to see command results, check status, or debug issues.",
{ {
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"), terminalName: z.string().describe("Name of the terminal"),
lines: z lines: z
.number() .number()
.optional() .optional()
@@ -206,19 +204,36 @@ server.tool(
"Milliseconds to wait before capturing output. Useful for slow commands." "Milliseconds to wait before capturing output. Useful for slow commands."
), ),
}, },
async ({ terminalId, lines, wait }) => { async ({ terminalName, lines, wait }) => {
try { try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Wait if specified // Wait if specified
if (wait) { if (wait) {
await new Promise((resolve) => setTimeout(resolve, wait)); await new Promise((resolve) => setTimeout(resolve, wait));
} }
// Get the pane for this terminal // Get the pane for this terminal
const panes = await tmux.listPanes(terminalId); const panes = await tmux.listPanes(window.id);
const pane = panes[0]; const pane = panes[0];
if (!pane) { if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`); throw new Error(`No pane found for terminal ${terminalName}`);
} }
const content = await tmux.capturePaneContent( const content = await tmux.capturePaneContent(
@@ -254,7 +269,7 @@ server.tool(
"send-text", "send-text",
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.", "Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.",
{ {
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"), terminalName: z.string().describe("Name of the terminal"),
text: z text: z
.string() .string()
.describe( .describe(
@@ -282,14 +297,31 @@ server.tool(
"Capture terminal output after sending text. Specify 'wait' for slow commands." "Capture terminal output after sending text. Specify 'wait' for slow commands."
), ),
}, },
async ({ terminalId, text, pressEnter, return_output }) => { async ({ terminalName, text, pressEnter, return_output }) => {
try { try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal // Get the pane for this terminal
const panes = await tmux.listPanes(terminalId); const panes = await tmux.listPanes(window.id);
const pane = panes[0]; const pane = panes[0];
if (!pane) { if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`); throw new Error(`No pane found for terminal ${terminalName}`);
} }
const output = await tmux.sendText({ const output = await tmux.sendText({
@@ -304,7 +336,7 @@ server.tool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Text sent to terminal ${terminalId}${ text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : "" pressEnter ? " (with Enter)" : ""
}.\n\n--- Output ---\n${output}`, }.\n\n--- Output ---\n${output}`,
}, },
@@ -316,7 +348,7 @@ server.tool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Text sent to terminal ${terminalId}${ text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : "" pressEnter ? " (with Enter)" : ""
}.\n\nUse capture-terminal to verify the result.`, }.\n\nUse capture-terminal to verify the result.`,
}, },
@@ -341,7 +373,7 @@ server.tool(
"send-keys", "send-keys",
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.", "Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.",
{ {
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"), terminalName: z.string().describe("Name of the terminal"),
keys: z keys: z
.string() .string()
.describe( .describe(
@@ -368,14 +400,31 @@ server.tool(
"Capture terminal output after sending keys. Specify 'wait' for slow commands." "Capture terminal output after sending keys. Specify 'wait' for slow commands."
), ),
}, },
async ({ terminalId, keys, repeat, return_output }) => { async ({ terminalName, keys, repeat, return_output }) => {
try { try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal // Get the pane for this terminal
const panes = await tmux.listPanes(terminalId); const panes = await tmux.listPanes(window.id);
const pane = panes[0]; const pane = panes[0];
if (!pane) { if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`); throw new Error(`No pane found for terminal ${terminalName}`);
} }
const output = await tmux.sendKeys({ const output = await tmux.sendKeys({
@@ -390,7 +439,7 @@ server.tool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${ text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : "" repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\n--- Output ---\n${output}`, }.\n\n--- Output ---\n${output}`,
}, },
@@ -402,7 +451,7 @@ server.tool(
content: [ content: [
{ {
type: "text", type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${ text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : "" repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\nUse capture-terminal to verify the result.`, }.\n\nUse capture-terminal to verify the result.`,
}, },
@@ -427,17 +476,24 @@ server.tool(
"rename-terminal", "rename-terminal",
"Rename a terminal to a more descriptive name", "Rename a terminal to a more descriptive name",
{ {
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"), terminalName: z.string().describe("Current name of the terminal"),
name: z.string().describe("New name for the terminal"), newName: z.string().describe("New name for the terminal"),
}, },
async ({ terminalId, name }) => { async ({ terminalName, newName }) => {
try { try {
await tmux.renameWindow(terminalId, name); await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
await tmux.renameWindow(session.id, terminalName, newName);
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Terminal ${terminalId} renamed to "${name}"`, text: `Terminal "${terminalName}" renamed to "${newName}"`,
}, },
], ],
}; };
@@ -460,16 +516,33 @@ server.tool(
"kill-terminal", "kill-terminal",
"Close a terminal and end its shell session", "Close a terminal and end its shell session",
{ {
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"), terminalName: z.string().describe("Name of the terminal"),
}, },
async ({ terminalId }) => { async ({ terminalName }) => {
try { try {
await tmux.killWindow(terminalId); await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await tmux.killWindow(window.id);
return { return {
content: [ content: [
{ {
type: "text", type: "text",
text: `Terminal ${terminalId} has been closed`, text: `Terminal "${terminalName}" has been closed`,
}, },
], ],
}; };
@@ -511,7 +584,6 @@ server.resource("Terminals", "tmux://terminals", async () => {
: "unknown"; : "unknown";
return { return {
id: window.id,
name: window.name, name: window.name,
active: window.active, active: window.active,
workingDirectory, workingDirectory,
@@ -543,7 +615,7 @@ server.resource("Terminals", "tmux://terminals", async () => {
// Expose terminal content as a resource // Expose terminal content as a resource
server.resource( server.resource(
"Terminal Content", "Terminal Content",
new ResourceTemplate("tmux://terminal/{terminalId}", { new ResourceTemplate("tmux://terminal/{terminalName}", {
list: async () => { list: async () => {
try { try {
await ensureDefaultSession(); await ensureDefaultSession();
@@ -557,7 +629,7 @@ server.resource(
const terminalResources = windows.map((window) => ({ const terminalResources = windows.map((window) => ({
name: `Terminal: ${window.name} ${window.active ? "(active)" : ""}`, name: `Terminal: ${window.name} ${window.active ? "(active)" : ""}`,
uri: `tmux://terminal/${window.id}`, uri: `tmux://terminal/${encodeURIComponent(window.name)}`,
description: `Content from terminal ${window.name}`, description: `Content from terminal ${window.name}`,
})); }));
@@ -574,19 +646,35 @@ server.resource(
} }
}, },
}), }),
async (uri, { terminalId }) => { async (uri, { terminalName }) => {
try { try {
// Ensure terminalId is a string await ensureDefaultSession();
const terminalIdStr = Array.isArray(terminalId)
? terminalId[0] const session = await tmux.findSessionByName(DEFAULT_SESSION);
: terminalId; if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Ensure terminalName is a string
const terminalNameStr = Array.isArray(terminalName)
? terminalName[0]
: terminalName;
// Decode URI component in case terminal name has special characters
const decodedTerminalName = decodeURIComponent(terminalNameStr);
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, decodedTerminalName);
if (!window) {
throw new Error(`Terminal '${decodedTerminalName}' not found`);
}
// Get the pane for this terminal // Get the pane for this terminal
const panes = await tmux.listPanes(terminalIdStr); const panes = await tmux.listPanes(window.id);
const pane = panes[0]; const pane = panes[0];
if (!pane) { if (!pane) {
throw new Error(`No pane found for terminal ${terminalIdStr}`); throw new Error(`No pane found for terminal ${decodedTerminalName}`);
} }
const content = await tmux.capturePaneContent(pane.id, 200, false); const content = await tmux.capturePaneContent(pane.id, 200, false);
+78 -5
View File
@@ -1,6 +1,7 @@
import { exec as execCallback } from "child_process"; import { exec as execCallback } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import os from "node:os";
const exec = promisify(execCallback); const exec = promisify(execCallback);
@@ -111,6 +112,32 @@ export async function findSessionByName(
} }
} }
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === null;
}
/** /**
* List windows in a session * List windows in a session
*/ */
@@ -237,6 +264,20 @@ export async function createSession(name: string): Promise<TmuxSession | null> {
return findSessionByName(name); return findSessionByName(name);
} }
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~/')) {
const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir);
}
if (path === '~') {
return process.env.HOME || os.homedir();
}
return path;
}
/** /**
* Create a new window in a session with optional working directory and initial command * Create a new window in a session with optional working directory and initial command
*/ */
@@ -248,10 +289,20 @@ export async function createWindow(
command?: string; command?: string;
} }
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> { ): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory // Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`; let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) { if (options?.workingDirectory) {
newWindowCmd += ` -c '${options.workingDirectory}'`; // Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
} }
await executeTmux(newWindowCmd); await executeTmux(newWindowCmd);
@@ -313,13 +364,35 @@ export async function killPane(paneId: string): Promise<void> {
} }
/** /**
* Rename a tmux window by ID * Rename a tmux window by name or ID
*/ */
export async function renameWindow( export async function renameWindow(
windowId: string, sessionId: string,
name: string windowNameOrId: string,
newName: string
): Promise<void> { ): Promise<void> {
await executeTmux(`rename-window -t '${windowId}' '${name}'`); // 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 // Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`); await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
} }