refactor: finish companion rename migration

Complete the remaining pi-to-companion rename across companion-os, web, vm-orchestrator, docker, and archived fixtures.

Verification:
- semantic rg sweeps for Pi/piConfig/getPi/.pi runtime references
- npm run check in apps/companion-os (fails in this worktree: biome not found)

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Harivansh Rathi 2026-03-10 07:39:32 -05:00
parent e8fe3d54af
commit 536241053c
303 changed files with 3603 additions and 3602 deletions

5
packages/companion-teams/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.DS_Store
.companion
dist
*.log

View file

@ -0,0 +1,107 @@
# companion-teams: Agent Guide 🤖
This guide explains how `companion-teams` transforms your single companion agent into a coordinated team of specialists. It covers the roles, capabilities, and coordination patterns available to you as the **Team Lead**.
---
## 🎭 The Two Roles
In a `companion-teams` environment, there are two distinct types of agents:
### 1. The Team Lead (You)
The agent in your main terminal window. You are responsible for:
- **Strategy**: Creating the team and defining its goals.
- **Delegation**: Spawning teammates and assigning them specific roles.
- **Coordination**: Managing the shared task board and broadcasting updates.
- **Quality Control**: Reviewing plans and approving finished work.
### 2. Teammates (The Specialists)
Agents spawned in separate panes. They are designed for:
- **Focus**: Executing specific, isolated tasks (e.g., "Security Audit", "Frontend Refactor").
- **Parallelism**: Working on multiple parts of the project simultaneously.
- **Autonomy**: Checking their own inboxes, submitting plans, and reporting progress without constant hand-holding.
---
## 🛠 Capabilities
### 🚀 Specialist Spawning
You can create teammates with custom identities, models, and reasoning depths:
- **Custom Roles**: "Spawn a 'CSS Expert' to fix the layout shifts."
- **Model Selection**: Use `gpt-4o` for complex architecture and `haiku` for fast, repetitive tasks.
- **Thinking Levels**: Set thinking to `high` for deep reasoning or `off` for maximum speed.
### 📋 Shared Task Board
A centralized source of truth for the entire team:
- **Visibility**: Everyone can see the full task list and who owns what.
- **Status Tracking**: Tasks move through `pending``planning``in_progress``completed`.
- **Ownership**: Assigning a task to a teammate automatically notifies them.
### 💬 Coordination & Messaging
Communication flows naturally between team members:
- **Direct Messaging**: Send specific instructions to one teammate.
- **Broadcasts**: Announce global changes (like API updates) to everyone at once.
- **Inbox Polling**: Teammates automatically "wake up" to check for new work every 30 seconds when idle.
### 🛡️ Plan Approval Mode
For critical changes, you can require teammates to submit a plan before they start:
1. Teammate analyzes the task and calls `task_submit_plan`.
2. You review the plan in the Lead pane.
3. You `approve` (to start work) or `reject` (with feedback for revision).
---
## 💡 Coordination Patterns
### Pattern 1: The "Parallel Sprint"
Use this when you have 3-4 independent features to build.
1. Create a team: `team_create({ team_name: "feature-sprint" })`
2. Spawn specialists for each feature.
3. Create tasks for each specialist.
4. Monitor progress while you work on the core architecture.
### Pattern 2: The "Safety First" Audit
Use this for refactoring or security work.
1. Spawn a teammate with `plan_mode_required: true`.
2. Assign the refactoring task.
3. Review their proposed changes before any code is touched.
4. Approve the plan to let them execute.
### Pattern 3: The "Quality Gate"
Use automated hooks to ensure standards.
1. Define a script at `.companion/team-hooks/task_completed.sh`.
2. When any teammate marks a task as `completed`, the hook runs (e.g., runs `npm test`).
3. If the hook fails, you'll know the work isn't ready.
---
## 🛑 When to Use companion-teams
- **Complex Projects**: Tasks that involve multiple files and logic layers.
- **Research & Execution**: One agent researches while another implements.
- **Parallel Testing**: Running different test suites in parallel.
- **Code Review**: Having one agent write code and another (specialized) agent review it.
## ⚠️ Best Practices
- **Isolation**: Give teammates tasks that don't overlap too much to avoid git conflicts.
- **Clear Prompts**: Be specific about the teammate's role and boundaries when spawning.
- **Check-ins**: Use `task_list` regularly to see the "big picture" of your team's progress.

View file

View file

View file

View file

@ -0,0 +1,188 @@
# companion-teams 🚀
**companion-teams** turns your single companion agent into a coordinated software engineering team. It allows you to spawn multiple "Teammate" agents in separate terminal panes that work autonomously, communicate with each other, and manage a shared task board-all mediated through tmux, Zellij, iTerm2, or WezTerm.
### 🖥️ companion-teams in Action
| iTerm2 | tmux | Zellij |
| :----------------------------------------------------------------------------------: | :----------------------------------------------------------------------------: | :----------------------------------------------------------------------------------: |
| <a href="iTerm2.png"><img src="iTerm2.png" width="300" alt="companion-teams in iTerm2"></a> | <a href="tmux.png"><img src="tmux.png" width="300" alt="companion-teams in tmux"></a> | <a href="zellij.png"><img src="zellij.png" width="300" alt="companion-teams in Zellij"></a> |
_Also works with **WezTerm** (cross-platform support)_
## 🛠 Installation
Open your companion terminal and type:
```bash
companion install npm:companion-teams
```
## 🚀 Quick Start
```bash
# 1. Start a team (inside tmux, Zellij, or iTerm2)
"Create a team named 'my-team' using 'gpt-4o'"
# 2. Spawn teammates
"Spawn 'security-bot' to scan for vulnerabilities"
"Spawn 'frontend-dev' using 'haiku' for quick iterations"
# 3. Create and assign tasks
"Create a task for security-bot: 'Audit auth endpoints'"
# 4. Review and approve work
"List all tasks and approve any pending plans"
```
## 🌟 What can it do?
### Core Features
- **Spawn Specialists**: Create agents like "Security Expert" or "Frontend Pro" to handle sub-tasks in parallel.
- **Shared Task Board**: Keep everyone on the same page with a persistent list of tasks and their status.
- **Agent Messaging**: Agents can send direct messages to each other and to you (the Team Lead) to report progress.
- **Autonomous Work**: Teammates automatically "wake up," read their instructions, and poll their inboxes for new work while idle.
- **Beautiful UI**: Optimized vertical splits in `tmux` with clear labels so you always know who is doing what.
### Advanced Features
- **Isolated OS Windows**: Launch teammates in true separate OS windows instead of panes.
- **Persistent Window Titles**: Windows are automatically titled `[team-name]: [agent-name]` for easy identification in your window manager.
- **Plan Approval Mode**: Require teammates to submit their implementation plans for your approval before they touch any code.
- **Broadcast Messaging**: Send a message to the entire team at once for global coordination and announcements.
- **Quality Gate Hooks**: Automated shell scripts run when tasks are completed (e.g., to run tests or linting).
- **Thinking Level Control**: Set per-teammate thinking levels (`off`, `minimal`, `low`, `medium`, `high`) to balance speed vs. reasoning depth.
## 💬 Key Examples
### 1. Start a Team
> **You:** "Create a team named 'my-app-audit' for reviewing the codebase."
**Set a default model for the whole team:**
> **You:** "Create a team named 'Research' and use 'gpt-4o' for everyone."
**Start a team in "Separate Windows" mode:**
> **You:** "Create a team named 'Dev' and open everyone in separate windows."
> _(Supported in iTerm2 and WezTerm only)_
### 2. Spawn Teammate with Custom Settings
> **You:** "Spawn a teammate named 'security-bot' in the current folder. Tell them to scan for hardcoded API keys."
**Spawn a specific teammate in a separate window:**
> **You:** "Spawn 'researcher' in a separate window."
**Move the Team Lead to a separate window:**
> **You:** "Open the team lead in its own window."
> _(Requires separate_windows mode enabled or iTerm2/WezTerm)_
**Use a different model:**
> **You:** "Spawn a teammate named 'speed-bot' using 'haiku' to quickly run some benchmarks."
**Require plan approval:**
> **You:** "Spawn a teammate named 'refactor-bot' and require plan approval before they make any changes."
**Customize model and thinking level:**
> **You:** "Spawn a teammate named 'architect-bot' using 'gpt-4o' with 'high' thinking level for deep reasoning."
**Smart Model Resolution:**
When you specify a model name without a provider (e.g., `gemini-2.5-flash`), companion-teams automatically:
- Queries available models from `companion --list-models`
- Prioritizes **OAuth/subscription providers** (cheaper/free) over API-key providers:
- `google-gemini-cli` (OAuth) is preferred over `google` (API key)
- `github-copilot`, `kimi-sub` are preferred over their API-key equivalents
- Falls back to API-key providers if OAuth providers aren't available
- Constructs the correct `--model provider/model:thinking` command
> **Example:** Specifying `gemini-2.5-flash` will automatically use `google-gemini-cli/gemini-2.5-flash` if available, saving API costs.
### 3. Assign Task & Get Approval
> **You:** "Create a task for security-bot: 'Check the .env.example file for sensitive defaults' and set it to in_progress."
Teammates in `planning` mode will use `task_submit_plan`. As the lead, review their work:
> **You:** "Review refactor-bot's plan for task 5. If it looks good, approve it. If not, reject it with feedback on the test coverage."
### 4. Broadcast to Team
> **You:** "Broadcast to the entire team: 'The API endpoint has changed to /v2. Please update your work accordingly.'"
### 5. Shut Down Team
> **You:** "We're done. Shut down the team and close the panes."
---
## 📚 Learn More
- **[Full Usage Guide](docs/guide.md)** - Detailed examples, hook system, best practices, and troubleshooting
- **[Tool Reference](docs/reference.md)** - Complete documentation of all tools and parameters
## 🪟 Terminal Requirements
To show multiple agents on one screen, **companion-teams** requires a way to manage terminal panes. It supports **tmux**, **Zellij**, **iTerm2**, and **WezTerm**.
### Option 1: tmux (Recommended)
Install tmux:
- **macOS**: `brew install tmux`
- **Linux**: `sudo apt install tmux`
How to run:
```bash
tmux # Start tmux session
companion # Start companion inside tmux
```
### Option 2: Zellij
Simply start `companion` inside a Zellij session. **companion-teams** will detect it via the `ZELLIJ` environment variable and use `zellij run` to spawn teammates in new panes.
### Option 3: iTerm2 (macOS)
If you are using **iTerm2** on macOS and are _not_ inside tmux or Zellij, **companion-teams** can manage your team in two ways:
1. **Panes (Default)**: Automatically split your current window into an optimized layout.
2. **Windows**: Create true separate OS windows for each agent.
It will name the panes or windows with the teammate's agent name for easy identification.
### Option 4: WezTerm (macOS, Linux, Windows)
**WezTerm** is a GPU-accelerated, cross-platform terminal emulator written in Rust. Like iTerm2, it supports both **Panes** and **Separate OS Windows**.
Install WezTerm:
- **macOS**: `brew install --cask wezterm`
- **Linux**: See [wezterm.org/installation](https://wezterm.org/installation)
- **Windows**: Download from [wezterm.org](https://wezterm.org)
How to run:
```bash
wezterm # Start WezTerm
companion # Start companion inside WezTerm
```
## 📜 Credits & Attribution
This project is a port of the excellent [claude-code-teams-mcp](https://github.com/cs50victor/claude-code-teams-mcp) by [cs50victor](https://github.com/cs50victor).
We have adapted the original MCP coordination protocol to work natively as a **companion package**, adding features like auto-starting teammates, balanced vertical UI layouts, automatic inbox polling, plan approval mode, broadcast messaging, and quality gate hooks.
## 📄 License
MIT

View file

@ -0,0 +1,66 @@
# WezTerm Panel Layout Fix
## Problem
WezTerm was not creating the correct panel layout for companion-teams. The desired layout is:
- **Main controller panel** on the LEFT (takes 70% width)
- **Teammate panels** stacked on the RIGHT (takes 30% width, divided vertically)
This matches the layout behavior in tmux and iTerm2.
## Root Cause
The WezTermAdapter was sequentially spawning panes without tracking which pane should be the "right sidebar." When using `split-pane --bottom`, it would split the currently active pane (which could be any teammate pane), rather than always splitting within the designated right sidebar area.
## Solution
Modified `src/adapters/wezterm-adapter.ts`:
1. **Added sidebar tracking**: Store the pane ID of the first teammate spawn (`sidebarPaneId`)
2. **Fixed split logic**:
- **First teammate** (paneCounter=0): Split RIGHT with 30% width (leaves 70% for main)
- **Subsequent teammates**: Split the saved sidebar pane BOTTOM with 50% height
3. **Used `--pane-id` parameter**: WezTerm CLI's `--pane-id` ensures we always split within the right sidebar, not whichever pane is currently active
## Code Changes
```typescript
private sidebarPaneId: string | null = null; // Track the right sidebar pane
spawn(options: SpawnOptions): string {
// First pane: split RIGHT (creates right sidebar)
// Subsequent panes: split BOTTOM within the sidebar pane
const isFirstPane = this.paneCounter === 0;
const weztermArgs = [
"cli",
"split-pane",
isFirstPane ? "--right" : "--bottom",
"--percent", isFirstPane ? "30" : "50",
...(isFirstPane ? [] : ["--pane-id", this.sidebarPaneId!]), // Key: always split in sidebar
"--cwd", options.cwd,
// ... rest of args
];
// ... execute command ...
// Track sidebar pane on first spawn
if (isFirstPane) {
this.sidebarPaneId = paneId;
}
}
```
## Result
✅ Main controller stays on the left at full height
✅ Teammates stack vertically on the right at equal heights
✅ Matches tmux/iTerm2 layout behavior
✅ All existing tests pass
## Testing
```bash
npm test -- src/adapters/wezterm-adapter.test.ts
# ✓ 17 tests passed
```

View file

@ -0,0 +1,115 @@
# WezTerm Terminal Support
## Summary
Successfully added support for **WezTerm** terminal emulator to companion-teams, bringing the total number of supported terminals to **4**:
- tmux (multiplexer)
- Zellij (multiplexer)
- iTerm2 (macOS)
- **WezTerm** (cross-platform) ✨ NEW
## Implementation Details
### Files Created
1. **`src/adapters/wezterm-adapter.ts`** (89 lines)
- Implements TerminalAdapter interface for WezTerm
- Uses `wezterm cli split-pane` for spawning panes
- Supports auto-layout: first pane splits left (30%), subsequent panes split bottom (50%)
- Pane ID prefix: `wezterm_%pane_id`
2. **`src/adapters/wezterm-adapter.test.ts`** (157 lines)
- 17 test cases covering all adapter methods
- Tests detection, spawning, killing, isAlive, and setTitle
### Files Modified
1. **`src/adapters/terminal-registry.ts`**
- Imported WezTermAdapter
- Added to adapters array with proper priority order
- Updated documentation
2. **`README.md`**
- Updated headline to mention WezTerm
- Added "Also works with WezTerm" note
- Added Option 4: WezTerm (installation and usage instructions)
## Detection Priority Order
The registry now detects terminals in this priority order:
1. **tmux** - if `TMUX` env is set
2. **Zellij** - if `ZELLIJ` env is set and not in tmux
3. **iTerm2** - if `TERM_PROGRAM=iTerm.app` and not in tmux/zellij
4. **WezTerm** - if `WEZTERM_PANE` env is set and not in tmux/zellij
## How Easy Was This?
**Extremely easy** thanks to the modular design!
### What We Had to Do:
1. ✅ Create adapter file implementing the same 5-method interface
2. ✅ Create test file
3. ✅ Add import statement to registry
4. ✅ Add adapter to the array
5. ✅ Update README documentation
### What We Didn't Need to Change:
- ❌ No changes to the core teams logic
- ❌ No changes to messaging system
- ❌ No changes to task management
- ❌ No changes to the spawn_teammate tool
- ❌ No changes to any other adapter
### Code Statistics:
- **New lines of code**: ~246 lines (adapter + tests)
- **Modified lines**: ~20 lines (registry + README)
- **Files added**: 2
- **Files modified**: 2
- **Time to implement**: ~20 minutes
## Test Results
All tests passing:
```
✓ src/adapters/wezterm-adapter.test.ts (17 tests)
✓ All existing tests (still passing)
```
Total: **46 tests passing**, 0 failures
## Key Features
### WezTerm Adapter
- ✅ CLI-based pane management (`wezterm cli split-pane`)
- ✅ Auto-layout: left split for first pane (30%), bottom splits for subsequent (50%)
- ✅ Environment variable filtering (only `COMPANION_*` prefixed)
- ✅ Graceful error handling
- ✅ Pane killing via Ctrl-C
- ✅ Tab title setting
## Cross-Platform Benefits
WezTerm is cross-platform:
- macOS ✅
- Linux ✅
- Windows ✅
This means companion-teams now works out-of-the-box on **more platforms** without requiring multiplexers like tmux or Zellij.
## Conclusion
The modular design with the TerminalAdapter interface made adding support for WezTerm incredibly straightforward. The pattern of:
1. Implement `detect()`, `spawn()`, `kill()`, `isAlive()`, `setTitle()`
2. Add to registry
3. Write tests
...is clean, maintainable, and scalable. Adding future terminal support will be just as easy!

View file

View file

@ -0,0 +1,396 @@
# companion-teams Usage Guide
This guide provides detailed examples, patterns, and best practices for using companion-teams.
## Table of Contents
- [Getting Started](#getting-started)
- [Common Workflows](#common-workflows)
- [Hook System](#hook-system)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)
---
## Getting Started
### Basic Team Setup
First, make sure you're inside a tmux session, Zellij session, or iTerm2:
```bash
tmux # or zellij, or just use iTerm2
```
Then start companion:
```bash
companion
```
Create your first team:
> **You:** "Create a team named 'my-team'"
Set a default model for all teammates:
> **You:** "Create a team named 'Research' and use 'gpt-4o' for everyone"
---
## Common Workflows
### 1. Code Review Team
> **You:** "Create a team named 'code-review' using 'gpt-4o'"
> **You:** "Spawn a teammate named 'security-reviewer' to check for vulnerabilities"
> **You:** "Spawn a teammate named 'performance-reviewer' using 'haiku' to check for optimization opportunities"
> **You:** "Create a task for security-reviewer: 'Review the auth module for SQL injection risks' and set it to in_progress"
> **You:** "Create a task for performance-reviewer: 'Analyze the database queries for N+1 issues' and set it to in_progress"
### 2. Refactor with Plan Approval
> **You:** "Create a team named 'refactor-squad'"
> **You:** "Spawn a teammate named 'refactor-bot' and require plan approval before they make any changes"
> **You:** "Create a task for refactor-bot: 'Refactor the user service to use dependency injection' and set it to in_progress"
Teammate submits a plan. Review it:
> **You:** "List all tasks and show me refactor-bot's plan for task 1"
Approve or reject:
> **You:** "Approve refactor-bot's plan for task 1"
> **You:** "Reject refactor-bot's plan for task 1 with feedback: 'Add unit tests for the new injection pattern'"
### 3. Testing with Automated Hooks
Create a hook script at `.companion/team-hooks/task_completed.sh`:
```bash
#!/bin/bash
# This script runs automatically when any task is completed
echo "Running post-task checks..."
npm test
if [ $? -ne 0 ]; then
echo "Tests failed! Please fix before marking task complete."
exit 1
fi
npm run lint
echo "All checks passed!"
```
> **You:** "Create a team named 'test-team'"
> **You:** "Spawn a teammate named 'qa-bot' to write tests"
> **You:** "Create a task for qa-bot: 'Write unit tests for the payment module' and set it to in_progress"
When qa-bot marks the task as completed, the hook automatically runs tests and linting.
### 4. Coordinated Migration
> **You:** "Create a team named 'migration-team'"
> **You:** "Spawn a teammate named 'db-migrator' to handle database changes"
> **You:** "Spawn a teammate named 'api-updater' using 'gpt-4o' to update API endpoints"
> **You:** "Spawn a teammate named 'test-writer' to write tests for the migration"
> **You:** "Create a task for db-migrator: 'Add new columns to the users table' and set it to in_progress"
After db-migrator completes, broadcast the schema change:
> **You:** "Broadcast to the team: 'New columns added to users table: phone, email_verified. Please update your code accordingly.'"
### 5. Mixed-Speed Team
Use different models for cost optimization:
> **You:** "Create a team named 'mixed-speed' using 'gpt-4o'"
> **You:** "Spawn a teammate named 'architect' using 'gpt-4o' with 'high' thinking level for design decisions"
> **You:** "Spawn a teammate named 'implementer' using 'haiku' with 'low' thinking level for quick coding"
> **You:** "Spawn a teammate named 'reviewer' using 'gpt-4o' with 'medium' thinking level for code reviews"
Now you have expensive reasoning for design and reviews, but fast/cheap implementation.
---
## Hook System
### Overview
Hooks are shell scripts that run automatically at specific events. Currently supported:
- **`task_completed.sh`** - Runs when any task's status changes to `completed`
### Hook Location
Hooks should be placed in `.companion/team-hooks/` in your project directory:
```
your-project/
├── .companion/
│ └── team-hooks/
│ └── task_completed.sh
```
### Hook Payload
The hook receives the task data as a JSON string as the first argument:
```bash
#!/bin/bash
TASK_DATA="$1"
echo "Task completed: $TASK_DATA"
```
Example payload:
```json
{
"id": "task_123",
"subject": "Fix login bug",
"description": "Users can't login with special characters",
"status": "completed",
"owner": "fixer-bot"
}
```
### Example Hooks
#### Test on Completion
```bash
#!/bin/bash
# .companion/team-hooks/task_completed.sh
TASK_DATA="$1"
SUBJECT=$(echo "$TASK_DATA" | jq -r '.subject')
echo "Running tests after task: $SUBJECT"
npm test
```
#### Notify Slack
```bash
#!/bin/bash
# .companion/team-hooks/task_completed.sh
TASK_DATA="$1"
SUBJECT=$(echo "$TASK_DATA" | jq -r '.subject')
OWNER=$(echo "$TASK_DATA" | jq -r '.owner')
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"Task '$SUBJECT' completed by $OWNER\"}" \
"$SLACK_WEBHOOK_URL"
```
#### Conditional Checks
```bash
#!/bin/bash
# .companion/team-hooks/task_completed.sh
TASK_DATA="$1"
SUBJECT=$(echo "$TASK_DATA" | jq -r '.subject')
# Only run full test suite for production-related tasks
if [[ "$SUBJECT" == *"production"* ]] || [[ "$SUBJECT" == *"deploy"* ]]; then
npm run test:ci
else
npm test
fi
```
---
## Best Practices
### 1. Use Thinking Levels Wisely
- **`off`** - Simple tasks: formatting, moving code, renaming
- **`minimal`** - Quick decisions: small refactors, straightforward bugfixes
- **`low`** - Standard work: typical feature implementation, tests
- **`medium`** - Complex work: architecture decisions, tricky bugs
- **`high`** - Critical work: security reviews, major refactors, design specs
### 2. Team Composition
Balanced teams typically include:
- **1-2 high-thinking, high-model** agents for architecture and reviews
- **2-3 low-thinking, fast-model** agents for implementation
- **1 medium-thinking** agent for coordination
Example:
```bash
# Design/Review duo (expensive but thorough)
spawn "architect" using "gpt-4o" with "high" thinking
spawn "reviewer" using "gpt-4o" with "medium" thinking
# Implementation trio (fast and cheap)
spawn "backend-dev" using "haiku" with "low" thinking
spawn "frontend-dev" using "haiku" with "low" thinking
spawn "test-writer" using "haiku" with "off" thinking
```
### 3. Plan Approval for High-Risk Changes
Enable plan approval mode for:
- Database schema changes
- API contract changes
- Security-related work
- Performance-critical code
Disable for:
- Documentation updates
- Test additions
- Simple bug fixes
### 4. Broadcast for Coordination
Use broadcasts when:
- API endpoints change
- Database schemas change
- Deployment happens
- Team priorities shift
### 5. Clear Task Descriptions
Good task:
```
"Add password strength validation to the signup form.
Requirements: minimum 8 chars, at least one number and symbol.
Use the zxcvbn library for strength calculation."
```
Bad task:
```
"Fix signup form"
```
### 6. Check Progress Regularly
> **You:** "List all tasks"
> **You:** "Check my inbox for messages"
> **You:** "How is the team doing?"
This helps you catch blockers early and provide feedback.
---
## Troubleshooting
### Teammate Not Responding
**Problem**: A teammate is idle but not picking up messages.
**Solution**:
1. Check if they're still running:
> **You:** "Check on teammate named 'security-bot'"
2. Check their inbox:
> **You:** "Read security-bot's inbox"
3. Force kill and respawn if needed:
> **You:** "Force kill security-bot and respawn them"
### tmux Pane Issues
**Problem**: tmux panes don't close when killing teammates.
**Solution**: Make sure you started companion inside a tmux session. If you started companion outside tmux, it won't work properly.
```bash
# Correct way
tmux
companion
# Incorrect way
companion # Then try to use tmux commands
```
### Hook Not Running
**Problem**: Your task_completed.sh script isn't executing.
**Checklist**:
1. File exists at `.companion/team-hooks/task_completed.sh`
2. File is executable: `chmod +x .companion/team-hooks/task_completed.sh`
3. Shebang line is present: `#!/bin/bash`
4. Test manually: `.companion/team-hooks/task_completed.sh '{"test":"data"}'`
### Model Errors
**Problem**: "Model not found" or similar errors.
**Solution**: Check the model name is correct and available in your companion config. Some model names vary between providers:
- `gpt-4o` - OpenAI
- `haiku` - Anthropic (usually `claude-3-5-haiku`)
- `glm-4.7` - Zhipu AI
Check your companion config for available models.
### Data Location
All team data is stored in:
- `~/.companion/teams/<team-name>/` - Team configuration, member list
- `~/.companion/tasks/<team-name>/` - Task files
- `~/.companion/messages/<team-name>/` - Message history
You can manually inspect these JSON files to debug issues.
### iTerm2 Not Working
**Problem**: iTerm2 splits aren't appearing.
**Requirements**:
1. You must be on macOS
2. iTerm2 must be your terminal
3. You must NOT be inside tmux or Zellij (iTerm2 detection only works as a fallback)
**Alternative**: Use tmux or Zellij for more reliable pane management.
---
## Inter-Agent Communication
Teammates can message each other without your intervention:
```
Frontend Bot → Backend Bot: "What's the response format for /api/users?"
Backend Bot → Frontend Bot: "Returns {id, name, email, created_at}"
```
This enables autonomous coordination. You can see these messages by:
> **You:** "Read backend-bot's inbox"
---
## Cleanup
To remove all team data:
```bash
# Shut down team first
> "Shut down the team named 'my-team'"
# Then delete data directory
rm -rf ~/.companion/teams/my-team/
rm -rf ~/.companion/tasks/my-team/
rm -rf ~/.companion/messages/my-team/
```
Or use the delete command:
> **You:** "Delete the team named 'my-team'"

View file

@ -0,0 +1,283 @@
# companion-teams Core Features Implementation Plan
> **REQUIRED SUB-SKILL:** Use the executing-plans skill to implement this plan task-by-task.
**Goal:** Implement Plan Approval Mode, Broadcast Messaging, and Quality Gate Hooks for the `companion-teams` repository to achieve functional parity with Claude Code Agent Teams.
**Architecture:**
- **Plan Approval**: Add a `planning` status to `TaskFile.status`. Create `task_submit_plan` and `task_evaluate_plan` tools. Lead can approve/reject.
- **Broadcast Messaging**: Add a `broadcast_message` tool that iterates through the team roster in `config.json` and sends messages to all active members.
- **Quality Gate Hooks**: Introduce a simple hook system that triggers on `task_update` (specifically when status becomes `completed`). For now, it will look for a `.companion/team-hooks/task_completed.sh` or similar.
**Tech Stack:** Node.js, TypeScript, Vitest
---
## Phase 1: Plan Approval Mode
### Task 1: Update Task Models and Statuses
**Files:**
- Modify: `src/utils/models.ts`
**Step 1: Add `planning` to `TaskFile.status` and add `plan` field**
```typescript
export interface TaskFile {
id: string;
subject: string;
description: string;
activeForm?: string;
status: "pending" | "in_progress" | "planning" | "completed" | "deleted";
blocks: string[];
blockedBy: string[];
owner?: string;
plan?: string;
planFeedback?: string;
metadata?: Record<string, any>;
}
```
**Step 2: Commit**
```bash
git add src/utils/models.ts
git commit -m "feat: add planning status to TaskFile"
```
### Task 2: Implement Plan Submission Tool
**Files:**
- Modify: `src/utils/tasks.ts`
- Test: `src/utils/tasks.test.ts`
**Step 1: Write test for `submitPlan`**
```typescript
it("should update task status to planning and save plan", async () => {
const task = await createTask("test-team", "Task 1", "Desc");
const updated = await submitPlan("test-team", task.id, "My Plan");
expect(updated.status).toBe("planning");
expect(updated.plan).toBe("My Plan");
});
```
**Step 2: Implement `submitPlan` in `tasks.ts`**
```typescript
export async function submitPlan(
teamName: string,
taskId: string,
plan: string,
): Promise<TaskFile> {
return await updateTask(teamName, taskId, { status: "planning", plan });
}
```
**Step 3: Run tests**
```bash
npx vitest run src/utils/tasks.test.ts
```
**Step 4: Commit**
```bash
git add src/utils/tasks.ts src/utils/tasks.test.ts
git commit -m "feat: implement submitPlan tool"
```
### Task 3: Implement Plan Evaluation Tool (Approve/Reject)
**Files:**
- Modify: `src/utils/tasks.ts`
- Test: `src/utils/tasks.test.ts`
**Step 1: Write test for `evaluatePlan`**
```typescript
it("should set status to in_progress on approval", async () => {
const task = await createTask("test-team", "Task 1", "Desc");
await submitPlan("test-team", task.id, "My Plan");
const approved = await evaluatePlan("test-team", task.id, "approve");
expect(approved.status).toBe("in_progress");
});
it("should set status back to in_progress or pending on reject with feedback", async () => {
const task = await createTask("test-team", "Task 1", "Desc");
await submitPlan("test-team", task.id, "My Plan");
const rejected = await evaluatePlan(
"test-team",
task.id,
"reject",
"More detail needed",
);
expect(rejected.status).toBe("in_progress"); // Teammate stays in implementation but needs to revise
expect(rejected.planFeedback).toBe("More detail needed");
});
```
**Step 2: Implement `evaluatePlan` in `tasks.ts`**
```typescript
export async function evaluatePlan(
teamName: string,
taskId: string,
action: "approve" | "reject",
feedback?: string,
): Promise<TaskFile> {
const status = action === "approve" ? "in_progress" : "in_progress"; // Simplified for now
return await updateTask(teamName, taskId, { status, planFeedback: feedback });
}
```
**Step 3: Run tests and commit**
```bash
npx vitest run src/utils/tasks.test.ts
git add src/utils/tasks.ts
git commit -m "feat: implement evaluatePlan tool"
```
---
## Phase 2: Broadcast Messaging
### Task 4: Implement Broadcast Messaging Tool
**Files:**
- Modify: `src/utils/messaging.ts`
- Test: `src/utils/messaging.test.ts`
**Step 1: Write test for `broadcastMessage`**
```typescript
it("should send message to all team members except sender", async () => {
// setup team with lead, m1, m2
await broadcastMessage(
"test-team",
"team-lead",
"Hello everyone!",
"Broadcast",
);
// verify m1 and m2 inboxes have the message
});
```
**Step 2: Implement `broadcastMessage`**
```typescript
import { readConfig } from "./teams";
export async function broadcastMessage(
teamName: string,
fromName: string,
text: string,
summary: string,
color?: string,
) {
const config = await readConfig(teamName);
for (const member of config.members) {
if (member.name !== fromName) {
await sendPlainMessage(
teamName,
fromName,
member.name,
text,
summary,
color,
);
}
}
}
```
**Step 3: Run tests and commit**
```bash
npx vitest run src/utils/messaging.test.ts
git add src/utils/messaging.ts
git commit -m "feat: implement broadcastMessage tool"
```
---
## Phase 3: Quality Gate Hooks
### Task 5: Implement Simple Hook System for Task Completion
**Files:**
- Modify: `src/utils/tasks.ts`
- Create: `src/utils/hooks.ts`
- Test: `src/utils/hooks.test.ts`
**Step 1: Create `hooks.ts` to run local hook scripts**
```typescript
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export function runHook(
teamName: string,
hookName: string,
payload: any,
): boolean {
const hookPath = path.join(
process.cwd(),
".companion",
"team-hooks",
`${hookName}.sh`,
);
if (!fs.existsSync(hookPath)) return true; // No hook, success
try {
const payloadStr = JSON.stringify(payload);
execSync(`sh ${hookPath} '${payloadStr}'`, { stdio: "inherit" });
return true;
} catch (e) {
console.error(`Hook ${hookName} failed`, e);
return false;
}
}
```
**Step 2: Modify `updateTask` in `tasks.ts` to trigger hook**
```typescript
// in updateTask, after saving:
if (updates.status === "completed") {
const success = runHook(teamName, "task_completed", updated);
if (!success) {
// Optionally revert or mark as failed
}
}
```
**Step 3: Write test and verify**
```bash
npx vitest run src/utils/hooks.test.ts
git add src/utils/tasks.ts src/utils/hooks.ts
git commit -m "feat: implement basic hook system for task completion"
```
---
## Phase 4: Expose New Tools to Agents
### Task 6: Expose Tools in extensions/index.ts
**Files:**
- Modify: `extensions/index.ts`
**Step 1: Add `broadcast_message`, `task_submit_plan`, and `task_evaluate_plan` tools**
**Step 2: Update `spawn_teammate` to include `plan_mode_required`**
**Step 3: Update `task_update` to allow `planning` status**

View file

@ -0,0 +1,703 @@
# companion-teams Tool Reference
Complete documentation of all tools, parameters, and automated behavior.
---
## Table of Contents
- [Team Management](#team-management)
- [Teammates](#teammates)
- [Task Management](#task-management)
- [Messaging](#messaging)
- [Task Planning & Approval](#task-planning--approval)
- [Automated Behavior](#automated-behavior)
- [Task Statuses](#task-statuses)
- [Configuration & Data](#configuration--data)
---
## Team Management
### team_create
Start a new team with optional default model.
**Parameters**:
- `team_name` (required): Name for the team
- `description` (optional): Team description
- `default_model` (optional): Default AI model for all teammates (e.g., `gpt-4o`, `haiku`, `glm-4.7`)
**Examples**:
```javascript
team_create({ team_name: "my-team" });
team_create({ team_name: "research", default_model: "gpt-4o" });
```
---
### team_delete
Delete a team and all its data (configuration, tasks, messages).
**Parameters**:
- `team_name` (required): Name of the team to delete
**Example**:
```javascript
team_delete({ team_name: "my-team" });
```
---
### read_config
Get details about the team and its members.
**Parameters**:
- `team_name` (required): Name of the team
**Returns**: Team configuration including:
- Team name and description
- Default model
- List of members with their models and thinking levels
- Creation timestamp
**Example**:
```javascript
read_config({ team_name: "my-team" });
```
---
## Teammates
### spawn_teammate
Launch a new agent into a terminal pane with a role and instructions.
**Parameters**:
- `team_name` (required): Name of the team
- `name` (required): Friendly name for the teammate (e.g., "security-bot")
- `prompt` (required): Instructions for the teammate's role and initial task
- `cwd` (required): Working directory for the teammate
- `model` (optional): AI model for this teammate (overrides team default)
- `thinking` (optional): Thinking level (`off`, `minimal`, `low`, `medium`, `high`)
- `plan_mode_required` (optional): If `true`, teammate must submit plans for approval
**Model Options**:
- Any model available in your companion configuration
- Common models: `gpt-4o`, `haiku` (Anthropic), `glm-4.7`, `glm-5` (Zhipu AI)
**Thinking Levels**:
- `off`: No thinking blocks (fastest)
- `minimal`: Minimal reasoning overhead
- `low`: Light reasoning for quick decisions
- `medium`: Balanced reasoning (default)
- `high`: Extended reasoning for complex problems
**Examples**:
```javascript
// Basic spawn
spawn_teammate({
team_name: "my-team",
name: "security-bot",
prompt: "Scan the codebase for hardcoded API keys",
cwd: "/path/to/project",
});
// With custom model
spawn_teammate({
team_name: "my-team",
name: "speed-bot",
prompt: "Run benchmarks on the API endpoints",
cwd: "/path/to/project",
model: "haiku",
});
// With plan approval
spawn_teammate({
team_name: "my-team",
name: "refactor-bot",
prompt: "Refactor the user service",
cwd: "/path/to/project",
plan_mode_required: true,
});
// With custom model and thinking
spawn_teammate({
team_name: "my-team",
name: "architect-bot",
prompt: "Design the new feature architecture",
cwd: "/path/to/project",
model: "gpt-4o",
thinking: "high",
});
```
---
### check_teammate
Check if a teammate is still running or has unread messages.
**Parameters**:
- `team_name` (required): Name of the team
- `agent_name` (required): Name of the teammate to check
**Returns**: Status information including:
- Whether the teammate is still running
- Number of unread messages
**Example**:
```javascript
check_teammate({ team_name: "my-team", agent_name: "security-bot" });
```
---
### force_kill_teammate
Forcibly kill a teammate's tmux pane and remove them from the team.
**Parameters**:
- `team_name` (required): Name of the team
- `agent_name` (required): Name of the teammate to kill
**Example**:
```javascript
force_kill_teammate({ team_name: "my-team", agent_name: "security-bot" });
```
---
### process_shutdown_approved
Initiate orderly shutdown for a finished teammate.
**Parameters**:
- `team_name` (required): Name of the team
- `agent_name` (required): Name of the teammate to shut down
**Example**:
```javascript
process_shutdown_approved({ team_name: "my-team", agent_name: "security-bot" });
```
---
## Task Management
### task_create
Create a new task for the team.
**Parameters**:
- `team_name` (required): Name of the team
- `subject` (required): Brief task title
- `description` (required): Detailed task description
- `status` (optional): Initial status (`pending`, `in_progress`, `planning`, `completed`, `deleted`). Default: `pending`
- `owner` (optional): Name of the teammate assigned to the task
**Example**:
```javascript
task_create({
team_name: "my-team",
subject: "Audit auth endpoints",
description:
"Review all authentication endpoints for SQL injection vulnerabilities",
status: "pending",
owner: "security-bot",
});
```
---
### task_list
List all tasks and their current status.
**Parameters**:
- `team_name` (required): Name of the team
**Returns**: Array of all tasks with their current status, owners, and details.
**Example**:
```javascript
task_list({ team_name: "my-team" });
```
---
### task_get
Get full details of a specific task.
**Parameters**:
- `team_name` (required): Name of the team
- `task_id` (required): ID of the task to retrieve
**Returns**: Full task object including:
- Subject and description
- Status and owner
- Plan (if in planning mode)
- Plan feedback (if rejected)
- Blocked relationships
**Example**:
```javascript
task_get({ team_name: "my-team", task_id: "task_abc123" });
```
---
### task_update
Update a task's status or owner.
**Parameters**:
- `team_name` (required): Name of the team
- `task_id` (required): ID of the task to update
- `status` (optional): New status (`pending`, `planning`, `in_progress`, `completed`, `deleted`)
- `owner` (optional): New owner (teammate name)
**Example**:
```javascript
task_update({
team_name: "my-team",
task_id: "task_abc123",
status: "in_progress",
owner: "security-bot",
});
```
**Note**: When status changes to `completed`, any hook script at `.companion/team-hooks/task_completed.sh` will automatically run.
---
## Messaging
### send_message
Send a message to a specific teammate or the team lead.
**Parameters**:
- `team_name` (required): Name of the team
- `recipient` (required): Name of the agent receiving the message
- `content` (required): Full message content
- `summary` (required): Brief summary for message list
- `color` (optional): Message color for UI highlighting
**Example**:
```javascript
send_message({
team_name: "my-team",
recipient: "security-bot",
content: "Please focus on the auth module first",
summary: "Focus on auth module",
});
```
---
### broadcast_message
Send a message to the entire team (excluding the sender).
**Parameters**:
- `team_name` (required): Name of the team
- `content` (required): Full message content
- `summary` (required): Brief summary for message list
- `color` (optional): Message color for UI highlighting
**Use cases**:
- API endpoint changes
- Database schema updates
- Team announcements
- Priority shifts
**Example**:
```javascript
broadcast_message({
team_name: "my-team",
content:
"The API endpoint has changed to /v2. Please update your work accordingly.",
summary: "API endpoint changed to v2",
});
```
---
### read_inbox
Read incoming messages for an agent.
**Parameters**:
- `team_name` (required): Name of the team
- `agent_name` (optional): Whose inbox to read. Defaults to current agent.
- `unread_only` (optional): Only show unread messages. Default: `true`
**Returns**: Array of messages with sender, content, timestamp, and read status.
**Examples**:
```javascript
// Read my unread messages
read_inbox({ team_name: "my-team" });
// Read all messages (including read)
read_inbox({ team_name: "my-team", unread_only: false });
// Read a teammate's inbox (as lead)
read_inbox({ team_name: "my-team", agent_name: "security-bot" });
```
---
## Task Planning & Approval
### task_submit_plan
For teammates to submit their implementation plans for approval.
**Parameters**:
- `team_name` (required): Name of the team
- `task_id` (required): ID of the task
- `plan` (required): Implementation plan description
**Behavior**:
- Updates task status to `planning`
- Saves the plan to the task
- Lead agent can then review and approve/reject
**Example**:
```javascript
task_submit_plan({
team_name: "my-team",
task_id: "task_abc123",
plan: "1. Add password strength validator component\n2. Integrate with existing signup form\n3. Add unit tests using zxcvbn library",
});
```
---
### task_evaluate_plan
For the lead agent to approve or reject a submitted plan.
**Parameters**:
- `team_name` (required): Name of the team
- `task_id` (required): ID of the task
- `action` (required): `"approve"` or `"reject"`
- `feedback` (optional): Feedback message (required when rejecting)
**Behavior**:
- **Approve**: Sets task status to `in_progress`, clears any previous feedback
- **Reject**: Sets task status back to `in_progress` (for revision), saves feedback
**Examples**:
```javascript
// Approve plan
task_evaluate_plan({
team_name: "my-team",
task_id: "task_abc123",
action: "approve",
});
// Reject with feedback
task_evaluate_plan({
team_name: "my-team",
task_id: "task_abc123",
action: "reject",
feedback: "Please add more detail about error handling and edge cases",
});
```
---
## Automated Behavior
### Initial Greeting
When a teammate is spawned, they automatically:
1. Send a message to the lead announcing they've started
2. Begin checking their inbox for work
**Example message**: "I've started and am checking my inbox for tasks."
---
### Idle Polling
If a teammate is idle (has no active work), they automatically check for new messages every **30 seconds**.
This ensures teammates stay responsive to new tasks, messages, and task reassignments without manual intervention.
---
### Automated Hooks
When a task's status changes to `completed`, companion-teams automatically executes:
`.companion/team-hooks/task_completed.sh`
The hook receives the task data as a JSON string as the first argument.
**Common hook uses**:
- Run test suite
- Run linting
- Notify external systems (Slack, email)
- Trigger deployments
- Generate reports
**See [Usage Guide](guide.md#hook-system) for detailed examples.**
---
### Context Injection
Each teammate is given a custom system prompt that includes:
- Their role and instructions
- Team context (team name, member list)
- Available tools
- Team environment guidelines
This ensures teammates understand their responsibilities and can work autonomously.
---
## Task Statuses
### pending
Task is created but not yet assigned or started.
### planning
Task is being planned. Teammate has submitted a plan and is awaiting lead approval. (Only available when `plan_mode_required` is true for the teammate)
### in_progress
Task is actively being worked on by the assigned teammate.
### completed
Task is finished. Status change triggers the `task_completed.sh` hook.
### deleted
Task is removed from the active task list. Still preserved in data history.
---
## Configuration & Data
### Data Storage
All companion-teams data is stored in your home directory under `~/.companion/`:
```
~/.companion/
├── teams/
│ └── <team-name>/
│ └── config.json # Team configuration and member list
├── tasks/
│ └── <team-name>/
│ ├── task_*.json # Individual task files
│ └── tasks.json # Task index
└── messages/
└── <team-name>/
├── <agent-name>.json # Per-agent message history
└── index.json # Message index
```
### Team Configuration (config.json)
```json
{
"name": "my-team",
"description": "Code review team",
"defaultModel": "gpt-4o",
"members": [
{
"name": "security-bot",
"model": "gpt-4o",
"thinking": "medium",
"planModeRequired": true
},
{
"name": "frontend-dev",
"model": "haiku",
"thinking": "low",
"planModeRequired": false
}
]
}
```
### Task File (task\_\*.json)
```json
{
"id": "task_abc123",
"subject": "Audit auth endpoints",
"description": "Review all authentication endpoints for vulnerabilities",
"status": "in_progress",
"owner": "security-bot",
"plan": "1. Scan /api/login\n2. Scan /api/register\n3. Scan /api/refresh",
"planFeedback": null,
"blocks": [],
"blockedBy": [],
"activeForm": "Auditing auth endpoints",
"createdAt": "2024-02-22T10:00:00Z",
"updatedAt": "2024-02-22T10:30:00Z"
}
```
### Message File (<agent-name>.json)
```json
{
"messages": [
{
"id": "msg_def456",
"from": "team-lead",
"to": "security-bot",
"content": "Please focus on the auth module first",
"summary": "Focus on auth module",
"timestamp": "2024-02-22T10:15:00Z",
"read": false
}
]
}
```
---
## Environment Variables
companion-teams respects the following environment variables:
- `ZELLIJ`: Automatically detected when running inside Zellij. Enables Zellij pane management.
- `TMUX`: Automatically detected when running inside tmux. Enables tmux pane management.
- `COMPANION_DEFAULT_THINKING_LEVEL`: Default thinking level for spawned teammates if not specified (`off`, `minimal`, `low`, `medium`, `high`).
---
## Terminal Integration
### tmux Detection
If the `TMUX` environment variable is set, companion-teams uses `tmux split-window` to create panes.
**Layout**: Large lead pane on the left, teammates stacked on the right.
### Zellij Detection
If the `ZELLIJ` environment variable is set, companion-teams uses `zellij run` to create panes.
**Layout**: Same as tmux - large lead pane on left, teammates on right.
### iTerm2 Detection
If neither tmux nor Zellij is detected, and you're on macOS with iTerm2, companion-teams uses AppleScript to split the window.
**Layout**: Same as tmux/Zellij - large lead pane on left, teammates on right.
**Requirements**:
- macOS
- iTerm2 terminal
- Not inside tmux or Zellij
---
## Error Handling
### Lock Files
companion-teams uses lock files to prevent concurrent modifications:
```
~/.companion/teams/<team-name>/.lock
~/.companion/tasks/<team-name>/.lock
~/.companion/messages/<team-name>/.lock
```
If a lock file is stale (process no longer running), it's automatically removed after 60 seconds.
### Race Conditions
The locking system prevents race conditions when multiple teammates try to update tasks or send messages simultaneously.
### Recovery
If a lock file persists beyond 60 seconds, it's automatically cleaned up. For manual recovery:
```bash
# Remove stale lock
rm ~/.companion/teams/my-team/.lock
```
---
## Performance Considerations
### Idle Polling Overhead
Teammates poll their inboxes every 30 seconds when idle. This is minimal overhead (one file read per poll).
### Lock Timeout
Lock files timeout after 60 seconds. Adjust if you have very slow operations.
### Message Storage
Messages are stored as JSON. For teams with extensive message history, consider periodic cleanup:
```bash
# Archive old messages
mv ~/.companion/messages/my-team/ ~/.companion/messages-archive/my-team-2024-02-22/
```

View file

@ -0,0 +1,467 @@
# Terminal.app Tab Management Research Report
**Researcher:** researcher
**Team:** refactor-team
**Date:** 2026-02-22
**Status:** Complete
---
## Executive Summary
After extensive testing of Terminal.app's AppleScript interface for tab management, **we strongly recommend AGAINST supporting Terminal.app tabs** in our project. The AppleScript interface is fundamentally broken for tab creation, highly unstable, and prone to hanging/timeout issues.
### Key Findings
| Capability | Status | Reliability |
| ---------------------------------- | -------------------- | ------------------------ |
| Create new tabs via AppleScript | ❌ **BROKEN** | Fails consistently |
| Create new windows via AppleScript | ✅ Works | Stable |
| Get tab properties | ⚠️ Partial | Unstable, prone to hangs |
| Set tab custom title | ✅ Works | Mostly stable |
| Switch between tabs | ❌ **NOT SUPPORTED** | N/A |
| Close specific tabs | ❌ **NOT SUPPORTED** | N/A |
| Get tab identifiers | ⚠️ Partial | Unstable |
| Overall stability | ❌ **POOR** | Prone to timeouts |
---
## Detailed Findings
### 1. Tab Creation Attempts
#### Method 1: `make new tab`
```applescript
tell application "Terminal"
set newTab to make new tab at end of tabs of window 1
end tell
```
**Result:** ❌ **FAILS** with error:
```
Terminal got an error: AppleEvent handler failed. (-10000)
```
**Analysis:** The AppleScript dictionary for Terminal.app includes `make new tab` syntax, but the underlying handler is not implemented or is broken. This API exists but does not function.
#### Method 2: `do script in window`
```applescript
tell application "Terminal"
do script "echo 'test'" in window 1
end tell
```
**Result:** ⚠️ **PARTIAL** - Executes command in existing tab, does NOT create new tab
**Analysis:** Despite documentation suggesting this might create tabs, it merely runs commands in the existing tab.
#### Method 3: `do script` without window specification
```applescript
tell application "Terminal"
do script "echo 'test'"
end tell
```
**Result:** ✅ Creates new **WINDOW**, not tab
**Analysis:** This is the only reliable way to create a new terminal session, but it creates a separate window, not a tab within the same window.
### 2. Tab Management Operations
#### Getting Tab Count
```applescript
tell application "Terminal"
get count of tabs of window 1
end tell
```
**Result:** ✅ Works, but always returns 1 (windows have only 1 tab)
#### Setting Tab Custom Title
```applescript
tell application "Terminal"
set custom title of tab 1 of window 1 to "My Title"
end tell
```
**Result:** ✅ **WORKS** - Can set custom titles on tabs
#### Getting Tab Properties
```applescript
tell application "Terminal"
get properties of tab 1 of window 1
end tell
```
**Result:** ❌ **UNSTABLE** - Frequently times out with error:
```
Terminal got an error: AppleEvent timed out. (-1712)
```
### 3. Menu and Keyboard Interface Testing
#### "New Tab" Menu Item
```applescript
tell application "System Events"
tell process "Terminal"
click menu item "New Tab" of menu "Shell" of menu bar 1
end tell
end tell
```
**Result:** ❌ Creates new **WINDOW**, not tab
**Analysis:** Despite being labeled "New Tab", Terminal.app's menu item creates separate windows in the current configuration.
#### Cmd+T Keyboard Shortcut
```applescript
tell application "System Events"
tell process "Terminal"
keystroke "t" using command down
end tell
end tell
```
**Result:** ❌ **TIMEOUT** - Causes AppleScript to hang and timeout
**Analysis:** This confirms the stability issues the team has experienced. Keyboard shortcut automation is unreliable.
### 4. Stability Issues
#### Observed Timeouts and Hangs
Multiple operations cause AppleScript to hang and timeout:
1. **Getting tab properties** - Frequent timeouts
2. **Cmd+T keyboard shortcut** - Consistent timeout
3. **Even simple operations** - Under load, even `count of windows` has timed out
Example timeout errors:
```
Terminal got an error: AppleEvent timed out. (-1712)
```
#### AppleScript Interface Reliability
| Operation | Success Rate | Notes |
| -------------------- | ------------ | ---------------- |
| Get window count | ~95% | Generally stable |
| Get window name | ~95% | Stable |
| Get window id | ~95% | Stable |
| Get tab properties | ~40% | Highly unstable |
| Set tab custom title | ~80% | Mostly works |
| Create new tab | 0% | Never works |
| Create new window | ~95% | Stable |
---
## Terminal.app vs. Alternative Emulators
### iTerm2 Considerations
While not tested in this research, iTerm2 is known to have:
- More robust AppleScript support
- Actual tab functionality that works
- Better automation capabilities
**Recommendation:** If tab support is critical, consider adding iTerm2 support as an alternative terminal emulator.
---
## What IS Possible with Terminal.app
### ✅ Working Features
1. **Create new windows:**
```applescript
tell application "Terminal"
do script "echo 'new window'"
end tell
```
2. **Set window/tab titles:**
```applescript
tell application "Terminal"
set custom title of tab 1 of window 1 to "Agent Workspace"
end tell
```
3. **Get window information:**
```applescript
tell application "Terminal"
set winId to id of window 1
set winName to name of window 1
end tell
```
4. **Close windows:**
```applescript
tell application "Terminal"
close window 1 saving no
end tell
```
5. **Execute commands in specific window:**
```applescript
tell application "Terminal"
do script "cd /path/to/project" in window 1
end tell
```
---
## What is NOT Possible with Terminal.app
### ❌ Broken or Unsupported Features
1. **Create new tabs within a window** - API exists but broken
2. **Switch between tabs** - Not supported via AppleScript
3. **Close specific tabs** - Not supported via AppleScript
4. **Reliable tab property access** - Prone to timeouts
5. **Track tab IDs** - Tab objects can't be reliably serialized/stored
6. **Automate keyboard shortcuts** - Causes hangs
---
## Stability Assessment
### Critical Issues
1. **AppleEvent Timeouts (-1712)**
- Occur frequently with tab-related operations
- Can cause entire automation workflow to hang
- No reliable way to prevent or recover from these
2. **Non-functional APIs**
- `make new tab` exists but always fails
- Creates false impression of functionality
3. **Inconsistent Behavior**
- Same operation may work 3 times, then timeout
- No pattern to predict failures
### Performance Impact
| Operation | Average Time | Timeout Frequency |
| ------------------------ | ------------ | ----------------- |
| Get window count | ~50ms | Rare |
| Get tab properties | ~200ms | Frequent |
| Create new window | ~100ms | Rare |
| Create new tab (attempt) | ~2s+ | Always times out |
---
## Recommendations
### For the companion-teams Project
**Primary Recommendation:**
> **Do NOT implement Terminal.app tab support.** Use separate windows instead.
**Rationale:**
1. **Technical Feasibility:** Tab creation via AppleScript is fundamentally broken
2. **Stability:** The interface is unreliable and prone to hangs
3. **User Experience:** Windows are functional and stable
4. **Maintenance:** Working around broken APIs would require complex, fragile code
### Alternative Approaches
#### Option 1: Windows Only (Recommended)
```javascript
// Create separate windows for each teammate
createTeammateWindow(name, command) {
return `tell application "Terminal"
do script "${command}"
set custom title of tab 1 of window 1 to "${name}"
end tell`;
}
```
#### Option 2: iTerm2 Support (If Tabs Required)
- Implement iTerm2 as an alternative terminal
- iTerm2 has working tab support via AppleScript
- Allow users to choose between Terminal (windows) and iTerm2 (tabs)
#### Option 3: Shell-based Solution
- Use shell commands to spawn terminals with specific titles
- Less integrated but more reliable
- Example: `osascript -e 'tell app "Terminal" to do script ""'`
---
## Code Examples
### Working: Create Window with Custom Title
```applescript
tell application "Terminal"
activate
do script ""
set custom title of tab 1 of window 1 to "Team Member: researcher"
end tell
```
### Working: Execute Command in Specific Window
```applescript
tell application "Terminal"
do script "cd /path/to/project" in window 1
do script "npm run dev" in window 1
end tell
```
### Working: Close Window
```applescript
tell application "Terminal"
close window 1 saving no
end tell
```
### Broken: Create Tab (Does NOT Work)
```applescript
tell application "Terminal"
-- This fails with "AppleEvent handler failed"
make new tab at end of tabs of window 1
end tell
```
### Unstable: Get Tab Properties (May Timeout)
```applescript
tell application "Terminal"
-- This frequently causes AppleEvent timeouts
get properties of tab 1 of window 1
end tell
```
---
## Testing Methodology
### Tests Performed
1. **Fresh Terminal.app Instance** - Started fresh for each test category
2. **Multiple API Attempts** - Tested each method 5+ times
3. **Stress Testing** - Multiple rapid operations to expose race conditions
4. **Error Analysis** - Captured all error types and frequencies
5. **Timing Measurements** - Measured operation duration and timeout patterns
### Test Environment
- macOS Version: [detected from system]
- Terminal.app Version: [system default]
- AppleScript Version: 2.7+
---
## Conclusion
Terminal.app's AppleScript interface for tab management is **not suitable for production use**. The APIs that exist are broken, unstable, or incomplete. Attempting to build tab management on top of this interface would result in:
- Frequent hangs and timeouts
- Complex error handling and retry logic
- Poor user experience
- High maintenance burden
**The recommended approach is to use separate windows for each teammate, which is stable, reliable, and well-supported.**
If tab functionality is absolutely required for the project, consider:
1. Implementing iTerm2 support as an alternative
2. Using a shell-based approach with tmux or screen
3. Building a custom terminal wrapper application
---
## Appendix: Complete Test Results
### Test 1: Tab Creation via `make new tab`
```
Attempts: 10
Successes: 0
Failures: 10 (all "AppleEvent handler failed")
Conclusion: Does not work
```
### Test 2: Tab Creation via `do script in window`
```
Attempts: 10
Created tabs: 0 (ran in existing tab)
Executed commands: 10
Conclusion: Does not create tabs
```
### Test 3: Tab Creation via `do script`
```
Attempts: 10
New windows created: 10
New tabs created: 0
Conclusion: Creates windows, not tabs
```
### Test 4: Tab Property Access
```
Attempts: 10
Successes: 4
Timeouts: 6
Average success time: 250ms
Conclusion: Unstable, not reliable
```
### Test 5: Keyboard Shortcut (Cmd+T)
```
Attempts: 3
Successes: 0
Timeouts: 3
Conclusion: Causes hangs, avoid
```
### Test 6: Window Creation
```
Attempts: 10
Successes: 10
Average time: 95ms
Conclusion: Stable and reliable
```
### Test 7: Set Custom Title
```
Attempts: 10
Successes: 9
Average time: 60ms
Conclusion: Reliable
```
---
**Report End**

View file

@ -0,0 +1,58 @@
### 1. Set Up the Team with Plan Approval
First, create a team and spawn a teammate who is required to provide a plan before making changes.
Prompt:
"Create a team named 'v060-test' for refactoring the project. Spawn a teammate named 'architect' and require plan approval before they make any changes. Tell them to start by identifying one small refactoring opportunity in any file."
---
### 2. Submit and Review a Plan
Wait for the architect to identifying a task and move into planning status.
Prompt (Wait for architect's turn):
"Check the task list. If refactor-bot has submitted a plan for a task, read it. If it involves actual code changes, reject it with feedback: 'Please include a test case in your plan for this change.' If they haven't submitted a plan yet, tell them to do so for task #1."
---
### 3. Evaluate a Plan (Approve)
Wait for the architect to revise the plan and re-submit.
Prompt (Wait for architect's turn):
"Check the task list for task #1. If the plan now includes a test case, approve it and tell the architect to begin implementation. If not, tell them they must include a test case."
---
### 4. Broadcast a Message
Test the new team-wide messaging capability.
Prompt:
"Broadcast to the entire team: 'New project-wide rule: all new files must include a header comment with the project name. Please update any work in progress.'"
---
### 5. Automated Hooks
Test the shell-based hook system. First, create a hook script, then mark a task as completed.
Prompt:
"Create a shell script at '.companion/team-hooks/task_completed.sh' that echoes the task ID and status to a file called 'hook_results.txt'. Then, mark task #1 as 'completed' and verify that 'hook_results.txt' has been created."
---
### 6. Verify Team Status
Ensure the task_list and read_inbox tools are correctly reflecting all the new states and communications.
Prompt:
"Check the task list and read the team configuration. Does task #1 show as 'completed'? Does the architect show as 'teammate' in the roster? Check your own inbox for any final reports."
---
### Final Clean Up
Prompt:
"We're done with the test. Shut down the team and delete all configuration files."

View file

@ -0,0 +1,94 @@
### 1. Create Team with Default Model
First, set up a test team with a default model.
Prompt:
"Create a team named 'v070-test' for testing thinking levels. Use 'anthropic/claude-3-5-sonnet-latest' as the default model."
---
### 2. Spawn Teammates with Different Thinking Levels
Test the new thinking parameter by spawning three teammates with different settings.
Prompt:
"Spawn three teammates with different thinking levels:
- 'DeepThinker' with 'high' thinking level. Tell them they are an expert at complex architectural analysis.
- 'MediumBot' with 'medium' thinking level. Tell them they are a balanced worker.
- 'FastWorker' with 'low' thinking level. Tell them they need to work quickly."
---
### 3. Verify Thinking Levels in Team Config
Check that the thinking levels are correctly persisted in the team configuration.
Prompt:
"Read the config for the 'v070-test' team. Verify that DeepThinker has thinking level 'high', MediumBot has 'medium', and FastWorker has 'low'."
---
### 4. Test Environment Variable Propagation
Verify that the COMPANION_DEFAULT_THINKING_LEVEL environment variable is correctly set for each spawned process.
Prompt (run in terminal):
"Run 'ps aux | grep COMPANION_DEFAULT_THINKING_LEVEL' to check that the environment variables were passed to the spawned teammate processes."
---
### 5. Assign Tasks Based on Thinking Levels
Create tasks appropriate for each teammate's thinking level.
Prompt:
"Create a task for DeepThinker: 'Analyze the companion-teams codebase architecture and suggest improvements for scalability'. Set it to in_progress.
Create a task for FastWorker: 'List all TypeScript files in the src directory'. Set it to in_progress."
---
### 6. Verify Teammate Responsiveness
Check that all teammates are responsive and checking their inboxes.
Prompt:
"Check the status of DeepThinker, MediumBot, and FastWorker using the check_teammate tool. Then send a message to FastWorker asking them to confirm they received their task."
---
### 7. Test Minimal and Off Thinking Levels
Spawn additional teammates with lower thinking settings.
Prompt:
"Spawn two more teammates:
- 'MinimalRunner' with 'minimal' thinking level using model 'google/gemini-2.0-flash'.
- 'InstantRunner' with 'off' thinking level using model 'google/gemini-2.0-flash'.
Tell both to report their current thinking setting when they reply."
---
### 8. Verify All Thinking Levels Supported
Check the team config again to ensure all five thinking levels are represented correctly.
Prompt:
"Read the team config again. Verify that DeepThinker shows 'high', MediumBot shows 'medium', FastWorker shows 'low', MinimalRunner shows 'minimal', and InstantRunner shows 'off'."
---
### 9. Test Thinking Level Behavior
Observe how different thinking levels affect response times and depth.
Prompt:
"Send the same simple question to all five teammates: 'What is 2 + 2?' Compare their response times and the depth of their reasoning blocks (if visible)."
---
### Final Clean Up
Prompt:
"Shut down the v070-test team and delete all configuration files."

View file

@ -0,0 +1,920 @@
# VS Code & Cursor Terminal Integration Research
## Executive Summary
After researching VS Code and Cursor integrated terminal capabilities, **I recommend AGAINST implementing direct VS Code/Cursor terminal support for companion-teams at this time**. The fundamental issue is that VS Code does not provide a command-line API for spawning or managing terminal panes from within an integrated terminal. While a VS Code extension could theoretically provide this functionality, it would require users to install an additional extension and would not work "out of the box" like the current tmux/Zellij/iTerm2 solutions.
---
## Research Scope
This document investigates whether companion-teams can work with VS Code and Cursor integrated terminals, specifically:
1. Detecting when running inside VS Code/Cursor integrated terminal
2. Programmatically creating new terminal instances
3. Controlling terminal splits, tabs, or panels
4. Available APIs (VS Code API, Cursor API, command palette)
5. How other tools handle this
6. Feasibility and recommendations
---
## 1. Detection: Can We Detect VS Code/Cursor Terminals?
### ✅ YES - Environment Variables
VS Code and Cursor set environment variables that can be detected:
```bash
# VS Code integrated terminal
TERM_PROGRAM=vscode
TERM_PROGRAM_VERSION=1.109.5
# Cursor (which is based on VS Code)
TERM_PROGRAM=vscode-electron
# OR potentially specific Cursor variables
# Environment-resolving shell (set by VS Code at startup)
VSCODE_RESOLVING_ENVIRONMENT=1
```
**Detection Code:**
```typescript
detect(): boolean {
return process.env.TERM_PROGRAM === 'vscode' ||
process.env.TERM_PROGRAM === 'vscode-electron';
}
```
### Detection Test Script
```bash
#!/bin/bash
echo "=== Terminal Detection ==="
echo "TERM_PROGRAM: $TERM_PROGRAM"
echo "TERM_PROGRAM_VERSION: $TERM_PROGRAM_VERSION"
echo "VSCODE_PID: $VSCODE_PID"
echo "VSCODE_IPC_HOOK_CLI: $VSCODE_IPC_HOOK_CLI"
echo "VSCODE_RESOLVING_ENVIRONMENT: $VSCODE_RESOLVING_ENVIRONMENT"
```
---
## 2. Terminal Management: What IS Possible?
### ❌ Command-Line Tool Spawning (Not Possible)
**The VS Code CLI (`code` command) does NOT provide commands to:**
- Spawn new integrated terminals
- Split existing terminal panes
- Control terminal layout
- Get or manage terminal IDs
- Send commands to specific terminals
**Available CLI commands** (from `code --help`):
- Open files/folders: `code .`
- Diff/merge: `code --diff`, `code --merge`
- Extensions: `--install-extension`, `--list-extensions`
- Chat: `code chat "prompt"`
- Shell integration: `--locate-shell-integration-path <shell>`
- Remote/tunnels: `code tunnel`
**Nothing for terminal pane management from command line.**
### ❌ Shell Commands from Integrated Terminal
From within a VS Code integrated terminal, there are **NO shell commands** or escape sequences that can:
- Spawn new terminal panes
- Split the terminal
- Communicate with the VS Code host process
- Control terminal layout
The integrated terminal is just a pseudoterminal (pty) running a shell - it has no knowledge of or control over VS Code's terminal UI.
---
## 3. VS Code Extension API: What IS Possible
### ✅ Extension API - Terminal Management
**VS Code extensions have a rich API for terminal management:**
```typescript
// Create a new terminal
const terminal = vscode.window.createTerminal({
name: "My Terminal",
shellPath: "/bin/bash",
cwd: "/path/to/dir",
env: { MY_VAR: "value" },
location: vscode.TerminalLocation.Split, // or Panel, Editor
});
// Create a pseudoterminal (custom terminal)
const pty: vscode.Pseudoterminal = {
onDidWrite: writeEmitter.event,
open: () => {
/* ... */
},
close: () => {
/* ... */
},
handleInput: (data) => {
/* ... */
},
};
vscode.window.createTerminal({ name: "Custom", pty });
// Get list of terminals
const terminals = vscode.window.terminals;
const activeTerminal = vscode.window.activeTerminal;
// Terminal lifecycle events
vscode.window.onDidOpenTerminal((terminal) => {
/* ... */
});
vscode.window.onDidCloseTerminal((terminal) => {
/* ... */
});
```
### ✅ Terminal Options
Extensions can control:
- **Location**: `TerminalLocation.Panel` (bottom), `TerminalLocation.Editor` (tab), `TerminalLocation.Split` (split pane)
- **Working directory**: `cwd` option
- **Environment variables**: `env` option
- **Shell**: `shellPath` and `shellArgs`
- **Appearance**: `iconPath`, `color`, `name`
- **Persistence**: `isTransient`
### ✅ TerminalProfile API
Extensions can register custom terminal profiles:
```typescript
// package.json contribution
{
"contributes": {
"terminal": {
"profiles": [
{
"title": "Companion Teams Terminal",
"id": "companion-teams-terminal"
}
]
}
}
}
// Register provider
vscode.window.registerTerminalProfileProvider('companion-teams-terminal', {
provideTerminalProfile(token) {
return {
name: "Companion Teams Agent",
shellPath: "bash",
cwd: "/project/path"
};
}
});
```
---
## 4. Cursor IDE Capabilities
### Same as VS Code (with limitations)
**Cursor is based on VS Code** and uses the same extension API, but:
- Cursor may have restrictions on which extensions can be installed
- Cursor's extensions marketplace may differ from VS Code's
- Cursor has its own AI features that may conflict or integrate differently
**Fundamental limitation remains**: Cursor does not expose terminal management APIs to command-line tools, only to extensions running in its extension host process.
---
## 5. Alternative Approaches Investigated
### ❌ Approach 1: AppleScript (macOS only)
**Investigated**: Can we use AppleScript to control VS Code on macOS?
**Findings**:
- VS Code does have AppleScript support
- BUT: AppleScript support is focused on window management, file opening, and basic editor operations
- **No AppleScript dictionary entries for terminal management**
- Would not work on Linux/Windows
- Unreliable and fragile
**Conclusion**: Not viable.
### ❌ Approach 2: VS Code IPC/Socket Communication
**Investigated**: Can we communicate with VS Code via IPC sockets?
**Findings**:
- VS Code sets `VSCODE_IPC_HOOK_CLI` environment variable
- This is used by the `code` CLI to communicate with running instances
- BUT: The IPC protocol is **internal and undocumented**
- No public API for sending custom commands via IPC
- Would require reverse-engineering VS Code's IPC protocol
- Protocol may change between versions
**Conclusion**: Not viable (undocumented, unstable).
### ❌ Approach 3: Shell Integration Escape Sequences
**Investigated**: Can we use ANSI escape sequences or OSC (Operating System Command) codes to control VS Code terminals?
**Findings**:
- VS Code's shell integration uses specific OSC sequences for:
- Current working directory reporting
- Command start/end markers
- Prompt detection
- BUT: These sequences are **one-way** (terminal → VS Code)
- No OSC sequences for creating new terminals or splitting
- No bidirectional communication channel
**Conclusion**: Not viable (one-way only).
### ⚠️ Approach 4: VS Code Extension (Partial Solution)
**Investigated**: Create a VS Code extension that companion-teams can communicate with
**Feasible Design**:
1. companion-teams detects VS Code environment (`TERM_PROGRAM=vscode`)
2. companion-teams spawns child processes that communicate with the extension
3. Extension receives requests and creates terminals via VS Code API
**Communication Mechanisms**:
- **Local WebSocket server**: Extension starts server, companion-teams connects
- **Named pipes/Unix domain sockets**: On Linux/macOS
- **File system polling**: Write request files, extension reads them
- **Local HTTP server**: Easier cross-platform
**Example Architecture**:
```
┌─────────────┐
│ companion-teams │ ← Running in integrated terminal
│ (node.js) │
└──────┬──────┘
│ 1. HTTP POST /create-terminal
│ { name: "agent-1", cwd: "/path", command: "companion ..." }
┌───────────────────────────┐
│ companion-teams VS Code Extension │ ← Running in extension host
│ (TypeScript) │
└───────┬───────────────────┘
│ 2. vscode.window.createTerminal({...})
┌───────────────────────────┐
│ VS Code Terminal Pane │ ← New terminal created
│ (running companion) │
└───────────────────────────┘
```
**Pros**:
- ✅ Full access to VS Code terminal API
- ✅ Can split terminals, set names, control layout
- ✅ Cross-platform (works on Windows/Linux/macOS)
- ✅ Can integrate with VS Code UI (commands, status bar)
**Cons**:
- ❌ Users must install extension (additional dependency)
- ❌ Extension adds ~5-10MB to install
- ❌ Extension must be maintained alongside companion-teams
- ❌ Extension adds startup overhead
- ❌ Extension permissions/security concerns
- ❌ Not "plug and play" like tmux/Zellij
**Conclusion**: Technically possible but adds significant user friction.
---
## 6. Comparison with Existing companion-teams Adapters
| Feature | tmux | Zellij | iTerm2 | VS Code (CLI) | VS Code (Extension) |
| ----------------- | ------------------------ | ------------------------- | ------------------------ | --------------------- | ----------------------- |
| Detection env var | `TMUX` | `ZELLIJ` | `TERM_PROGRAM=iTerm.app` | `TERM_PROGRAM=vscode` | `TERM_PROGRAM=vscode` |
| Spawn terminal | ✅ `tmux split-window` | ✅ `zellij run` | ✅ AppleScript | ❌ **Not available** | ✅ `createTerminal()` |
| Set pane title | ✅ `tmux select-pane -T` | ✅ `zellij rename-pane` | ✅ AppleScript | ❌ **Not available** | ✅ `terminal.name` |
| Kill pane | ✅ `tmux kill-pane` | ✅ `zellij close-pane` | ✅ AppleScript | ❌ **Not available** | ✅ `terminal.dispose()` |
| Check if alive | ✅ `tmux has-session` | ✅ `zellij list-sessions` | ❌ Approximate | ❌ **Not available** | ✅ Track in extension |
| User setup | Install tmux | Install Zellij | iTerm2 only | N/A | Install extension |
| Cross-platform | ✅ Linux/macOS/Windows | ✅ Linux/macOS/Windows | ❌ macOS only | N/A | ✅ All platforms |
| Works out of box | ✅ | ✅ | ✅ (on macOS) | ❌ | ❌ (requires extension) |
---
## 7. How Other Tools Handle This
### ❌ Most Tools Don't Support VS Code Terminals
After researching popular terminal multiplexers and dev tools:
**tmux, Zellij, tmate, dtach**: Do not work with VS Code integrated terminals (require their own terminal emulator)
**node-pty**: Library for creating pseudoterminals, but doesn't integrate with VS Code's terminal UI
**xterm.js**: Browser-based terminal emulator, not applicable
### ✅ Some Tools Use VS Code Extensions
**Test Explorer extensions**: Create terminals for running tests
- Example: Python, Jest, .NET test extensions
- All run as VS Code extensions, not CLI tools
**Docker extension**: Creates terminals for containers
- Runs as extension, uses VS Code terminal API
**Remote - SSH extension**: Creates terminals for remote sessions
- Extension-hosted solution
**Pattern observed**: Tools that need terminal management in VS Code **are implemented as extensions**, not CLI tools.
---
## 8. Detailed Findings: What IS NOT Possible
### ❌ Cannot Spawn Terminals from CLI
The fundamental blocker: **VS Code provides no command-line or shell interface for terminal management**.
**Evidence**:
1. `code --help` shows 50+ commands, **none** for terminals
2. VS Code terminal is a pseudoterminal (pty) - shell has no awareness of VS Code
3. No escape sequences or OSC codes for creating terminals
4. VS Code IPC protocol is undocumented/internal
5. No WebSocket or other communication channels exposed
**Verification**: Tried all available approaches:
- `code` CLI: No terminal commands
- Environment variables: Detection only, not control
- Shell escape sequences: None exist for terminal creation
- AppleScript: No terminal support
- IPC sockets: Undocumented protocol
---
## 9. Cursor-Specific Research
### Cursor = VS Code + AI Features
**Key findings**:
1. Cursor is **built on top of VS Code**
2. Uses same extension API and most VS Code infrastructure
3. Extension marketplace may be different/restricted
4. **Same fundamental limitation**: No CLI API for terminal management
### Cursor Extension Ecosystem
- Cursor has its own extensions (some unique, some from VS Code)
- Extension development uses same VS Code Extension API
- May have restrictions on which extensions can run
**Conclusion for Cursor**: Same as VS Code - would require a Cursor-specific extension.
---
## 10. Recommended Approach
### 🚫 Recommendation: Do NOT Implement VS Code/Cursor Terminal Support
**Reasons**:
1. **No native CLI support**: VS Code provides no command-line API for terminal management
2. **Extension required**: Would require users to install and configure an extension
3. **User friction**: Adds setup complexity vs. "just use tmux"
4. **Maintenance burden**: Extension must be maintained alongside companion-teams
5. **Limited benefit**: Users can simply run `tmux` inside VS Code integrated terminal
6. **Alternative exists**: tmux/Zellij work perfectly fine inside VS Code terminals
### ✅ Current Solution: Users Run tmux/Zellij Inside VS Code
**Best practice for VS Code users**:
```bash
# Option 1: Run tmux inside VS Code integrated terminal
tmux new -s companion-teams
companion create-team my-team
companion spawn-teammate ...
# Option 2: Start tmux from terminal, then open VS Code
tmux new -s my-session
# Open VS Code with: code .
```
**Benefits**:
- ✅ Works out of the box
- ✅ No additional extensions needed
- ✅ Same experience across all terminals (VS Code, iTerm2, alacritty, etc.)
- ✅ Familiar workflow for terminal users
- ✅ No maintenance overhead
---
## 11. If You Must Support VS Code Terminals
### ⚠️ Extension-Based Approach (Recommended Only If Required)
If there's strong user demand for native VS Code integration:
#### Architecture
```
1. companion-teams detects VS Code (TERM_PROGRAM=vscode)
2. companion-teams spawns a lightweight HTTP server
- Port: Random free port (e.g., 34567)
- Endpoint: POST /create-terminal
- Payload: { name, cwd, command, env }
3. User installs "companion-teams" VS Code extension
- Extension starts HTTP client on activation
- Finds companion-teams server port via shared file or env var
4. Extension receives create-terminal requests
- Calls vscode.window.createTerminal()
- Returns terminal ID
5. companion-teams tracks terminal IDs via extension responses
```
#### Implementation Sketch
**companion-teams (TypeScript)**:
```typescript
class VSCodeAdapter implements TerminalAdapter {
name = "vscode";
detect(): boolean {
return process.env.TERM_PROGRAM === "vscode";
}
async spawn(options: SpawnOptions): Promise<string> {
// Start HTTP server if not running
const port = await ensureHttpServer();
// Write request file
const requestId = uuidv4();
await fs.writeFile(
`/tmp/companion-teams-request-${requestId}.json`,
JSON.stringify({ ...options, requestId }),
);
// Wait for response
const response = await waitForResponse(requestId);
return response.terminalId;
}
kill(paneId: string): void {
// Send kill request via HTTP
}
isAlive(paneId: string): boolean {
// Query extension via HTTP
}
setTitle(title: string): void {
// Send title update via HTTP
}
}
```
**VS Code Extension (TypeScript)**:
```typescript
export function activate(context: vscode.ExtensionContext) {
const port = readPortFromFile();
const httpClient = axios.create({ baseURL: `http://localhost:${port}` });
// Watch for request files
const watcher = vscode.workspace.createFileSystemWatcher(
"/tmp/companion-teams-request-*.json",
);
watcher.onDidChange(async (uri) => {
const request = JSON.parse(await vscode.workspace.fs.readFile(uri));
// Create terminal
const terminal = vscode.window.createTerminal({
name: request.name,
cwd: request.cwd,
env: request.env,
});
// Send response
await httpClient.post("/response", {
requestId: request.requestId,
terminalId: terminal.processId, // or unique ID
});
});
}
```
#### Pros/Cons of Extension Approach
| Aspect | Evaluation |
| --------------------- | -------------------------------------------- |
| Technical feasibility | ✅ Feasible with VS Code API |
| User experience | ⚠️ Good after setup, but setup required |
| Maintenance | ❌ High (extension + npm package) |
| Cross-platform | ✅ Works on all platforms |
| Development time | 🔴 High (~2-3 weeks for full implementation) |
| Extension size | ~5-10MB (TypeScript, bundled dependencies) |
| Extension complexity | Medium (HTTP server, file watching, IPC) |
| Security | ⚠️ Need to validate requests, prevent abuse |
#### Estimated Effort
- **Week 1**: Design architecture, prototype HTTP server, extension skeleton
- **Week 2**: Implement terminal creation, tracking, naming
- **Week 3**: Implement kill, isAlive, setTitle, error handling
- **Week 4**: Testing, documentation, packaging, publishing
**Total: 3-4 weeks of focused development**
---
## 12. Alternative Idea: VS Code Terminal Tab Detection
### Could We Detect Existing Terminal Tabs?
**Investigated**: Can companion-teams detect existing VS Code terminal tabs and use them?
**Findings**:
- VS Code extension API can get list of terminals: `vscode.window.terminals`
- BUT: This is only available to extensions, not CLI tools
- No command to list terminals from integrated terminal
**Conclusion**: Not possible without extension.
---
## 13. Terminal Integration Comparison Matrix
| Terminal Type | Detection | Spawn | Kill | Track Alive | Set Title | User Setup |
| ------------------- | --------- | ----------------- | ----------------- | ----------------- | ----------------- | ----------------- |
| tmux | ✅ Easy | ✅ Native | ✅ Native | ✅ Native | ✅ Native | Install tmux |
| Zellij | ✅ Easy | ✅ Native | ✅ Native | ✅ Native | ✅ Native | Install Zellij |
| iTerm2 | ✅ Easy | ✅ AppleScript | ✅ AppleScript | ❌ Approximate | ✅ AppleScript | None (macOS) |
| VS Code (CLI) | ✅ Easy | ❌ **Impossible** | ❌ **Impossible** | ❌ **Impossible** | ❌ **Impossible** | N/A |
| Cursor (CLI) | ✅ Easy | ❌ **Impossible** | ❌ **Impossible** | ❌ **Impossible** | ❌ **Impossible** | N/A |
| VS Code (Extension) | ✅ Easy | ✅ Via extension | ✅ Via extension | ✅ Via extension | ✅ Via extension | Install extension |
---
## 14. Environment Variables Reference
### VS Code Integrated Terminal Environment Variables
| Variable | Value | When Set | Use Case |
| ------------------------------ | ------------------------------ | ------------------------------------------------------------ | ------------------------ |
| `TERM_PROGRAM` | `vscode` | Always in integrated terminal | ✅ Detect VS Code |
| `TERM_PROGRAM_VERSION` | e.g., `1.109.5` | Always in integrated terminal | Version detection |
| `VSCODE_RESOLVING_ENVIRONMENT` | `1` | When VS Code launches environment-resolving shell at startup | Detect startup shell |
| `VSCODE_PID` | (unset in integrated terminal) | Set by extension host, not terminal | Not useful for detection |
| `VSCODE_IPC_HOOK_CLI` | Path to IPC socket | Set by extension host | Not useful for CLI tools |
### Cursor Environment Variables
| Variable | Value | When Set | Use Case |
| ---------------------- | ---------------------------- | ------------------------------------ | ----------------- |
| `TERM_PROGRAM` | `vscode-electron` or similar | Always in Cursor integrated terminal | ✅ Detect Cursor |
| `TERM_PROGRAM_VERSION` | Cursor version | Always in Cursor integrated terminal | Version detection |
### Other Terminal Environment Variables
| Variable | Value | Terminal |
| ------------------ | -------------------------------------- | ------------- |
| `TMUX` | Pane ID or similar | tmux |
| `ZELLIJ` | Session ID | Zellij |
| `ITERM_SESSION_ID` | Session UUID | iTerm2 |
| `TERM` | Terminal type (e.g., `xterm-256color`) | All terminals |
---
## 15. Code Examples
### Detection Code (Ready to Use)
```typescript
// src/adapters/vscode-adapter.ts
export class VSCodeAdapter implements TerminalAdapter {
readonly name = "vscode";
detect(): boolean {
return (
process.env.TERM_PROGRAM === "vscode" ||
process.env.TERM_PROGRAM === "vscode-electron"
);
}
spawn(options: SpawnOptions): string {
throw new Error(
"VS Code integrated terminals do not support spawning " +
"new terminals from command line. Please run companion-teams " +
"inside tmux, Zellij, or iTerm2 for terminal management. " +
"Alternatively, install the companion-teams VS Code extension " +
"(if implemented).",
);
}
kill(paneId: string): void {
throw new Error("Not supported in VS Code without extension");
}
isAlive(paneId: string): boolean {
return false;
}
setTitle(title: string): void {
throw new Error("Not supported in VS Code without extension");
}
}
```
### User-Facing Error Message
```
❌ Cannot spawn terminal in VS Code integrated terminal
companion-teams requires a terminal multiplexer to create multiple panes.
For VS Code users, we recommend one of these options:
Option 1: Run tmux inside VS Code integrated terminal
┌────────────────────────────────────────┐
│ $ tmux new -s companion-teams │
│ $ companion create-team my-team │
│ $ companion spawn-teammate security-bot ... │
└────────────────────────────────────────┘
Option 2: Open VS Code from tmux session
┌────────────────────────────────────────┐
│ $ tmux new -s my-session │
│ $ code . │
│ $ companion create-team my-team │
└────────────────────────────────────────┘
Option 3: Use a terminal with multiplexer support
┌────────────────────────────────────────┐
│ • iTerm2 (macOS) - Built-in support │
│ • tmux - Install: brew install tmux │
│ • Zellij - Install: cargo install ... │
└────────────────────────────────────────┘
Learn more: https://github.com/your-org/companion-teams#terminal-support
```
---
## 16. Conclusions and Recommendations
### Final Recommendation: ❌ Do Not Implement VS Code/Cursor Support
**Primary reasons**:
1. **No CLI API for terminal management**: VS Code provides no command-line interface for spawning or managing terminal panes.
2. **Extension-based solution required**: Would require users to install and configure a VS Code extension, adding significant user friction.
3. **Better alternative exists**: Users can simply run tmux or Zellij inside VS Code integrated terminal, achieving the same result without any additional work.
4. **Maintenance burden**: Maintaining both a Node.js package and a VS Code extension doubles the development and maintenance effort.
5. **Limited benefit**: The primary use case (multiple coordinated terminals in one screen) is already solved by tmux/Zellij/iTerm2.
### Recommended User Guidance
For VS Code/Cursor users, recommend:
```bash
# Option 1: Run tmux inside VS Code (simplest)
tmux new -s companion-teams
# Option 2: Start tmux first, then open VS Code
tmux new -s dev
code .
```
### Documentation Update
Add to companion-teams README.md:
````markdown
## Using companion-teams with VS Code or Cursor
companion-teams works great with VS Code and Cursor! Simply run tmux
or Zellij inside the integrated terminal:
```bash
# Start tmux in VS Code integrated terminal
$ tmux new -s companion-teams
$ companion create-team my-team
$ companion spawn-teammate security-bot "Scan for vulnerabilities"
```
````
Your team will appear in the integrated terminal with proper splits:
┌──────────────────┬──────────────────┐
│ Lead (Team) │ security-bot │
│ │ (scanning...) │
└──────────────────┴──────────────────┘
> **Why not native VS Code terminal support?**
> VS Code does not provide a command-line API for creating terminal
> panes. Using tmux or Zellij inside VS Code gives you the same
> multi-pane experience with no additional extensions needed.
````
---
## 17. Future Possibilities
### If VS Code Adds CLI Terminal API
Monitor VS Code issues and releases for:
- Terminal management commands in `code` CLI
- Public IPC protocol for terminal control
- WebSocket or REST API for terminal management
**Related VS Code issues**:
- (Search GitHub for terminal management CLI requests)
### If User Demand Is High
1. Create GitHub issue: "VS Code integration: Extension approach"
2. Gauge user interest and willingness to install extension
3. If strong demand, implement extension-based solution (Section 11)
### Alternative: Webview-Based Terminal Emulator
Consider building a custom terminal emulator using VS Code's webview API:
- Pros: Full control, no extension IPC needed
- Cons: Reinventing wheel, poor performance, limited terminal features
**Not recommended**: Significant effort for worse UX.
---
## Appendix A: Research Sources
### Official Documentation
- VS Code Terminal API: https://code.visualstudio.com/api/extension-guides/terminal
- VS Code Extension API: https://code.visualstudio.com/api/references/vscode-api
- VS Code CLI: https://code.visualstudio.com/docs/editor/command-line
- Terminal Basics: https://code.visualstudio.com/docs/terminal/basics
### GitHub Repositories
- VS Code: https://github.com/microsoft/vscode
- VS Code Extension Samples: https://github.com/microsoft/vscode-extension-samples
- Cursor: https://github.com/getcursor/cursor
### Key Resources
- `code --help` - Full CLI documentation
- VS Code API Reference - Complete API documentation
- Shell Integration docs - Environment variable reference
---
## Appendix B: Tested Approaches
### ❌ Approaches Tested and Rejected
1. **VS Code CLI Commands**
- Command: `code --help`
- Result: No terminal management commands found
- Conclusion: Not viable
2. **AppleScript (macOS)**
- Tested: AppleScript Editor dictionary for VS Code
- Result: No terminal-related verbs
- Conclusion: Not viable
3. **Shell Escape Sequences**
- Tested: ANSI/OSC codes for terminal control
- Result: No sequences for terminal creation
- Conclusion: Not viable
4. **Environment Variable Inspection**
- Tested: All VS Code/Cursor environment variables
- Result: Detection works, control doesn't
- Conclusion: Useful for detection only
5. **IPC Socket Investigation**
- Tested: `VSCODE_IPC_HOOK_CLI` variable
- Result: Undocumented protocol, no public API
- Conclusion: Not viable
### ✅ Approaches That Work
1. **tmux inside VS Code**
- Tested: `tmux new -s test` in integrated terminal
- Result: ✅ Full tmux functionality available
- Conclusion: Recommended approach
2. **Zellij inside VS Code**
- Tested: `zellij` in integrated terminal
- Result: ✅ Full Zellij functionality available
- Conclusion: Recommended approach
---
## Appendix C: Quick Reference
### Terminal Detection
```typescript
// VS Code
process.env.TERM_PROGRAM === 'vscode'
// Cursor
process.env.TERM_PROGRAM === 'vscode-electron'
// tmux
!!process.env.TMUX
// Zellij
!!process.env.ZELLIJ
// iTerm2
process.env.TERM_PROGRAM === 'iTerm.app'
````
### Why VS Code Terminals Don't Work
```
┌─────────────────────────────────────────────────────┐
│ VS Code Architecture │
├─────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Integrated │ │ Extension │ │
│ │ Terminal │◀────────│ Host │ │
│ │ (pty) │ NO API │ (TypeScript)│ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Shell │ ← Has no awareness of VS Code │
│ │ (bash/zsh) │ │
│ └──────────────┘ │
│ │
│ CLI tools running in shell cannot create new │
│ terminals because there's no API to call. │
└─────────────────────────────────────────────────────┘
```
### Recommended Workflow for VS Code Users
```bash
# Step 1: Start tmux
tmux new -s companion-teams
# Step 2: Use companion-teams
companion create-team my-team
companion spawn-teammate frontend-dev
companion spawn-teammate backend-dev
# Step 3: Enjoy multi-pane coordination
┌──────────────────┬──────────────────┬──────────────────┐
│ Team Lead │ frontend-dev │ backend-dev │
│ (you) │ (coding...) │ (coding...) │
└──────────────────┴──────────────────┴──────────────────┘
```
---
**Document Version**: 1.0
**Research Date**: February 22, 2026
**Researcher**: ide-researcher (refactor-team)
**Status**: Complete - Recommendation: Do NOT implement VS Code/Cursor terminal support

View file

@ -0,0 +1,818 @@
import type { ExtensionAPI } from "@mariozechner/companion-coding-agent";
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/companion-ai";
import * as paths from "../src/utils/paths";
import * as teams from "../src/utils/teams";
import * as tasks from "../src/utils/tasks";
import * as messaging from "../src/utils/messaging";
import { Member } from "../src/utils/models";
import { getTerminalAdapter } from "../src/adapters/terminal-registry";
import { Iterm2Adapter } from "../src/adapters/iterm2-adapter";
import * as path from "node:path";
import * as fs from "node:fs";
import { spawnSync } from "node:child_process";
// Cache for available models
let availableModelsCache: Array<{ provider: string; model: string }> | null =
null;
let modelsCacheTime = 0;
const MODELS_CACHE_TTL = 60000; // 1 minute
/**
* Query available models from companion --list-models
*/
function getAvailableModels(): Array<{ provider: string; model: string }> {
const now = Date.now();
if (availableModelsCache && now - modelsCacheTime < MODELS_CACHE_TTL) {
return availableModelsCache;
}
try {
const result = spawnSync("companion", ["--list-models"], {
encoding: "utf-8",
timeout: 10000,
});
if (result.status !== 0 || !result.stdout) {
return [];
}
const models: Array<{ provider: string; model: string }> = [];
const lines = result.stdout.split("\n");
for (const line of lines) {
// Skip header line and empty lines
if (!line.trim() || line.startsWith("provider")) continue;
// Parse: provider model context max-out thinking images
const parts = line.trim().split(/\s+/);
if (parts.length >= 2) {
const provider = parts[0];
const model = parts[1];
if (provider && model) {
models.push({ provider, model });
}
}
}
availableModelsCache = models;
modelsCacheTime = now;
return models;
} catch (e) {
return [];
}
}
/**
* Provider priority list - OAuth/subscription providers first (cheaper), then API-key providers
*/
const PROVIDER_PRIORITY = [
// OAuth / Subscription providers (typically free/cheaper)
"google-gemini-cli", // Google Gemini CLI - OAuth, free tier
"github-copilot", // GitHub Copilot - subscription
"kimi-sub", // Kimi subscription
// API key providers
"anthropic",
"openai",
"google",
"zai",
"openrouter",
"azure-openai",
"amazon-bedrock",
"mistral",
"groq",
"cerebras",
"xai",
"vercel-ai-gateway",
];
/**
* Find the best matching provider for a given model name.
* Returns the full provider/model string or null if not found.
*/
function resolveModelWithProvider(modelName: string): string | null {
// If already has provider prefix, return as-is
if (modelName.includes("/")) {
return modelName;
}
const availableModels = getAvailableModels();
if (availableModels.length === 0) {
return null;
}
const lowerModelName = modelName.toLowerCase();
// Find all exact matches (case-insensitive) and sort by provider priority
const exactMatches = availableModels.filter(
(m) => m.model.toLowerCase() === lowerModelName,
);
if (exactMatches.length > 0) {
// Sort by provider priority (lower index = higher priority)
exactMatches.sort((a, b) => {
const aIndex = PROVIDER_PRIORITY.indexOf(a.provider);
const bIndex = PROVIDER_PRIORITY.indexOf(b.provider);
// If provider not in priority list, put it at the end
const aPriority = aIndex === -1 ? 999 : aIndex;
const bPriority = bIndex === -1 ? 999 : bIndex;
return aPriority - bPriority;
});
return `${exactMatches[0].provider}/${exactMatches[0].model}`;
}
// Try partial match (model name contains the search term)
const partialMatches = availableModels.filter((m) =>
m.model.toLowerCase().includes(lowerModelName),
);
if (partialMatches.length > 0) {
for (const preferredProvider of PROVIDER_PRIORITY) {
const match = partialMatches.find(
(m) => m.provider === preferredProvider,
);
if (match) {
return `${match.provider}/${match.model}`;
}
}
// Return first match if no preferred provider found
return `${partialMatches[0].provider}/${partialMatches[0].model}`;
}
return null;
}
export default function (companion: ExtensionAPI) {
const isTeammate = !!process.env.COMPANION_AGENT_NAME;
const agentName = process.env.COMPANION_AGENT_NAME || "team-lead";
const teamName = process.env.COMPANION_TEAM_NAME;
const terminal = getTerminalAdapter();
companion.on("session_start", async (_event, ctx) => {
paths.ensureDirs();
if (isTeammate) {
if (teamName) {
const pidFile = path.join(paths.teamDir(teamName), `${agentName}.pid`);
fs.writeFileSync(pidFile, process.pid.toString());
}
ctx.ui.notify(`Teammate: ${agentName} (Team: ${teamName})`, "info");
ctx.ui.setStatus("00-companion-teams", `[${agentName.toUpperCase()}]`);
if (terminal) {
const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
const setIt = () => {
if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
terminal.setTitle(fullTitle);
};
setIt();
setTimeout(setIt, 500);
setTimeout(setIt, 2000);
setTimeout(setIt, 5000);
}
setTimeout(() => {
companion.sendUserMessage(
`I am starting my work as '${agentName}' on team '${teamName}'. Checking my inbox for instructions...`,
);
}, 1000);
setInterval(async () => {
if (ctx.isIdle() && teamName) {
const unread = await messaging.readInbox(
teamName,
agentName,
true,
false,
);
if (unread.length > 0) {
companion.sendUserMessage(
`I have ${unread.length} new message(s) in my inbox. Reading them now...`,
);
}
}
}, 30000);
} else if (teamName) {
ctx.ui.setStatus("companion-teams", `Lead @ ${teamName}`);
}
});
companion.on("turn_start", async (_event, ctx) => {
if (isTeammate) {
const fullTitle = teamName ? `${teamName}: ${agentName}` : agentName;
if ((ctx.ui as any).setTitle) (ctx.ui as any).setTitle(fullTitle);
if (terminal) terminal.setTitle(fullTitle);
}
});
let firstTurn = true;
companion.on("before_agent_start", async (event, ctx) => {
if (isTeammate && firstTurn) {
firstTurn = false;
let modelInfo = "";
if (teamName) {
try {
const teamConfig = await teams.readConfig(teamName);
const member = teamConfig.members.find((m) => m.name === agentName);
if (member && member.model) {
modelInfo = `\nYou are currently using model: ${member.model}`;
if (member.thinking) {
modelInfo += ` with thinking level: ${member.thinking}`;
}
modelInfo += `. When reporting your model or thinking level, use these exact values.`;
}
} catch (e) {
// Ignore
}
}
return {
systemPrompt:
event.systemPrompt +
`\n\nYou are teammate '${agentName}' on team '${teamName}'.\nYour lead is 'team-lead'.${modelInfo}\nStart by calling read_inbox(team_name="${teamName}") to get your initial instructions.`,
};
}
});
async function killTeammate(teamName: string, member: Member) {
if (member.name === "team-lead") return;
const pidFile = path.join(paths.teamDir(teamName), `${member.name}.pid`);
if (fs.existsSync(pidFile)) {
try {
const pid = fs.readFileSync(pidFile, "utf-8").trim();
process.kill(parseInt(pid), "SIGKILL");
fs.unlinkSync(pidFile);
} catch (e) {
// ignore
}
}
if (member.windowId && terminal) {
terminal.killWindow(member.windowId);
}
if (member.tmuxPaneId && terminal) {
terminal.kill(member.tmuxPaneId);
}
}
// Tools
companion.registerTool({
name: "team_create",
label: "Create Team",
description: "Create a new agent team.",
parameters: Type.Object({
team_name: Type.String(),
description: Type.Optional(Type.String()),
default_model: Type.Optional(Type.String()),
separate_windows: Type.Optional(
Type.Boolean({
default: false,
description: "Open teammates in separate OS windows instead of panes",
}),
),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const config = teams.createTeam(
params.team_name,
"local-session",
"lead-agent",
params.description,
params.default_model,
params.separate_windows,
);
return {
content: [{ type: "text", text: `Team ${params.team_name} created.` }],
details: { config },
};
},
});
companion.registerTool({
name: "spawn_teammate",
label: "Spawn Teammate",
description: "Spawn a new teammate in a terminal pane or separate window.",
parameters: Type.Object({
team_name: Type.String(),
name: Type.String(),
prompt: Type.String(),
cwd: Type.String(),
model: Type.Optional(Type.String()),
thinking: Type.Optional(
StringEnum(["off", "minimal", "low", "medium", "high"]),
),
plan_mode_required: Type.Optional(Type.Boolean({ default: false })),
separate_window: Type.Optional(Type.Boolean({ default: false })),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const safeName = paths.sanitizeName(params.name);
const safeTeamName = paths.sanitizeName(params.team_name);
if (!teams.teamExists(safeTeamName)) {
throw new Error(`Team ${params.team_name} does not exist`);
}
if (!terminal) {
throw new Error("No terminal adapter detected.");
}
const teamConfig = await teams.readConfig(safeTeamName);
let chosenModel = params.model || teamConfig.defaultModel;
// Resolve model to provider/model format
if (chosenModel) {
if (!chosenModel.includes("/")) {
// Try to resolve using available models from companion --list-models
const resolved = resolveModelWithProvider(chosenModel);
if (resolved) {
chosenModel = resolved;
} else if (
teamConfig.defaultModel &&
teamConfig.defaultModel.includes("/")
) {
// Fall back to team default provider
const [provider] = teamConfig.defaultModel.split("/");
chosenModel = `${provider}/${chosenModel}`;
}
}
}
const useSeparateWindow =
params.separate_window ?? teamConfig.separateWindows ?? false;
if (useSeparateWindow && !terminal.supportsWindows()) {
throw new Error(
`Separate windows mode is not supported in ${terminal.name}.`,
);
}
const member: Member = {
agentId: `${safeName}@${safeTeamName}`,
name: safeName,
agentType: "teammate",
model: chosenModel,
joinedAt: Date.now(),
tmuxPaneId: "",
cwd: params.cwd,
subscriptions: [],
prompt: params.prompt,
color: "blue",
thinking: params.thinking,
planModeRequired: params.plan_mode_required,
};
await teams.addMember(safeTeamName, member);
await messaging.sendPlainMessage(
safeTeamName,
"team-lead",
safeName,
params.prompt,
"Initial prompt",
);
const piBinary = "companion";
let piCmd = piBinary;
if (chosenModel) {
// Use the combined --model provider/model:thinking format
if (params.thinking) {
piCmd = `${piBinary} --model ${chosenModel}:${params.thinking}`;
} else {
piCmd = `${piBinary} --model ${chosenModel}`;
}
} else if (params.thinking) {
piCmd = `${piBinary} --thinking ${params.thinking}`;
}
const env: Record<string, string> = {
...process.env,
COMPANION_TEAM_NAME: safeTeamName,
COMPANION_AGENT_NAME: safeName,
};
let terminalId = "";
let isWindow = false;
try {
if (useSeparateWindow) {
isWindow = true;
terminalId = terminal.spawnWindow({
name: safeName,
cwd: params.cwd,
command: piCmd,
env: env,
teamName: safeTeamName,
});
await teams.updateMember(safeTeamName, safeName, {
windowId: terminalId,
});
} else {
if (terminal instanceof Iterm2Adapter) {
const teammates = teamConfig.members.filter(
(m) =>
m.agentType === "teammate" && m.tmuxPaneId.startsWith("iterm_"),
);
const lastTeammate =
teammates.length > 0 ? teammates[teammates.length - 1] : null;
if (lastTeammate?.tmuxPaneId) {
terminal.setSpawnContext({
lastSessionId: lastTeammate.tmuxPaneId.replace("iterm_", ""),
});
} else {
terminal.setSpawnContext({});
}
}
terminalId = terminal.spawn({
name: safeName,
cwd: params.cwd,
command: piCmd,
env: env,
});
await teams.updateMember(safeTeamName, safeName, {
tmuxPaneId: terminalId,
});
}
} catch (e) {
throw new Error(
`Failed to spawn ${terminal.name} ${isWindow ? "window" : "pane"}: ${e}`,
);
}
return {
content: [
{
type: "text",
text: `Teammate ${params.name} spawned in ${isWindow ? "window" : "pane"} ${terminalId}.`,
},
],
details: { agentId: member.agentId, terminalId, isWindow },
};
},
});
companion.registerTool({
name: "spawn_lead_window",
label: "Spawn Lead Window",
description: "Open the team lead in a separate OS window.",
parameters: Type.Object({
team_name: Type.String(),
cwd: Type.Optional(Type.String()),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const safeTeamName = paths.sanitizeName(params.team_name);
if (!teams.teamExists(safeTeamName))
throw new Error(`Team ${params.team_name} does not exist`);
if (!terminal || !terminal.supportsWindows())
throw new Error("Windows mode not supported.");
const teamConfig = await teams.readConfig(safeTeamName);
const cwd = params.cwd || process.cwd();
const piBinary = "companion";
let piCmd = piBinary;
if (teamConfig.defaultModel) {
// Use the combined --model provider/model format
piCmd = `${piBinary} --model ${teamConfig.defaultModel}`;
}
const env = {
...process.env,
COMPANION_TEAM_NAME: safeTeamName,
COMPANION_AGENT_NAME: "team-lead",
};
try {
const windowId = terminal.spawnWindow({
name: "team-lead",
cwd,
command: piCmd,
env,
teamName: safeTeamName,
});
await teams.updateMember(safeTeamName, "team-lead", { windowId });
return {
content: [{ type: "text", text: `Lead window spawned: ${windowId}` }],
details: { windowId },
};
} catch (e) {
throw new Error(`Failed: ${e}`);
}
},
});
companion.registerTool({
name: "send_message",
label: "Send Message",
description: "Send a message to a teammate.",
parameters: Type.Object({
team_name: Type.String(),
recipient: Type.String(),
content: Type.String(),
summary: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
await messaging.sendPlainMessage(
params.team_name,
agentName,
params.recipient,
params.content,
params.summary,
);
return {
content: [
{ type: "text", text: `Message sent to ${params.recipient}.` },
],
details: {},
};
},
});
companion.registerTool({
name: "broadcast_message",
label: "Broadcast Message",
description: "Broadcast a message to all team members except the sender.",
parameters: Type.Object({
team_name: Type.String(),
content: Type.String(),
summary: Type.String(),
color: Type.Optional(Type.String()),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
await messaging.broadcastMessage(
params.team_name,
agentName,
params.content,
params.summary,
params.color,
);
return {
content: [
{ type: "text", text: `Message broadcasted to all team members.` },
],
details: {},
};
},
});
companion.registerTool({
name: "read_inbox",
label: "Read Inbox",
description: "Read messages from an agent's inbox.",
parameters: Type.Object({
team_name: Type.String(),
agent_name: Type.Optional(
Type.String({
description: "Whose inbox to read. Defaults to your own.",
}),
),
unread_only: Type.Optional(Type.Boolean({ default: true })),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const targetAgent = params.agent_name || agentName;
const msgs = await messaging.readInbox(
params.team_name,
targetAgent,
params.unread_only,
);
return {
content: [{ type: "text", text: JSON.stringify(msgs, null, 2) }],
details: { messages: msgs },
};
},
});
companion.registerTool({
name: "task_create",
label: "Create Task",
description: "Create a new team task.",
parameters: Type.Object({
team_name: Type.String(),
subject: Type.String(),
description: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const task = await tasks.createTask(
params.team_name,
params.subject,
params.description,
);
return {
content: [{ type: "text", text: `Task ${task.id} created.` }],
details: { task },
};
},
});
companion.registerTool({
name: "task_submit_plan",
label: "Submit Plan",
description: "Submit a plan for a task, updating its status to 'planning'.",
parameters: Type.Object({
team_name: Type.String(),
task_id: Type.String(),
plan: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const updated = await tasks.submitPlan(
params.team_name,
params.task_id,
params.plan,
);
return {
content: [
{ type: "text", text: `Plan submitted for task ${params.task_id}.` },
],
details: { task: updated },
};
},
});
companion.registerTool({
name: "task_evaluate_plan",
label: "Evaluate Plan",
description: "Evaluate a submitted plan for a task.",
parameters: Type.Object({
team_name: Type.String(),
task_id: Type.String(),
action: StringEnum(["approve", "reject"]),
feedback: Type.Optional(
Type.String({ description: "Required for rejection" }),
),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const updated = await tasks.evaluatePlan(
params.team_name,
params.task_id,
params.action as any,
params.feedback,
);
return {
content: [
{
type: "text",
text: `Plan for task ${params.task_id} has been ${params.action}d.`,
},
],
details: { task: updated },
};
},
});
companion.registerTool({
name: "task_list",
label: "List Tasks",
description: "List all tasks for a team.",
parameters: Type.Object({
team_name: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const taskList = await tasks.listTasks(params.team_name);
return {
content: [{ type: "text", text: JSON.stringify(taskList, null, 2) }],
details: { tasks: taskList },
};
},
});
companion.registerTool({
name: "task_update",
label: "Update Task",
description: "Update a task's status or owner.",
parameters: Type.Object({
team_name: Type.String(),
task_id: Type.String(),
status: Type.Optional(
StringEnum([
"pending",
"planning",
"in_progress",
"completed",
"deleted",
]),
),
owner: Type.Optional(Type.String()),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const updated = await tasks.updateTask(params.team_name, params.task_id, {
status: params.status as any,
owner: params.owner,
});
return {
content: [{ type: "text", text: `Task ${params.task_id} updated.` }],
details: { task: updated },
};
},
});
companion.registerTool({
name: "team_shutdown",
label: "Shutdown Team",
description: "Shutdown the entire team and close all panes/windows.",
parameters: Type.Object({
team_name: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const teamName = params.team_name;
try {
const config = await teams.readConfig(teamName);
for (const member of config.members) {
await killTeammate(teamName, member);
}
const dir = paths.teamDir(teamName);
const tasksDir = paths.taskDir(teamName);
if (fs.existsSync(tasksDir)) fs.rmSync(tasksDir, { recursive: true });
if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true });
return {
content: [{ type: "text", text: `Team ${teamName} shut down.` }],
details: {},
};
} catch (e) {
throw new Error(`Failed to shutdown team: ${e}`);
}
},
});
companion.registerTool({
name: "task_read",
label: "Read Task",
description: "Read details of a specific task.",
parameters: Type.Object({
team_name: Type.String(),
task_id: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const task = await tasks.readTask(params.team_name, params.task_id);
return {
content: [{ type: "text", text: JSON.stringify(task, null, 2) }],
details: { task },
};
},
});
companion.registerTool({
name: "check_teammate",
label: "Check Teammate",
description: "Check a single teammate's status.",
parameters: Type.Object({
team_name: Type.String(),
agent_name: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const config = await teams.readConfig(params.team_name);
const member = config.members.find((m) => m.name === params.agent_name);
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
let alive = false;
if (member.windowId && terminal) {
alive = terminal.isWindowAlive(member.windowId);
} else if (member.tmuxPaneId && terminal) {
alive = terminal.isAlive(member.tmuxPaneId);
}
const unreadCount = (
await messaging.readInbox(
params.team_name,
params.agent_name,
true,
false,
)
).length;
return {
content: [
{
type: "text",
text: JSON.stringify({ alive, unreadCount }, null, 2),
},
],
details: { alive, unreadCount },
};
},
});
companion.registerTool({
name: "process_shutdown_approved",
label: "Process Shutdown Approved",
description: "Process a teammate's shutdown.",
parameters: Type.Object({
team_name: Type.String(),
agent_name: Type.String(),
}),
async execute(toolCallId, params: any, signal, onUpdate, ctx) {
const config = await teams.readConfig(params.team_name);
const member = config.members.find((m) => m.name === params.agent_name);
if (!member) throw new Error(`Teammate ${params.agent_name} not found`);
await killTeammate(params.team_name, member);
await teams.removeMember(params.team_name, params.agent_name);
return {
content: [
{
type: "text",
text: `Teammate ${params.agent_name} has been shut down.`,
},
],
details: {},
};
},
});
}

View file

@ -0,0 +1,114 @@
# Research Findings: Terminal Window Title Support
## iTerm2 (macOS)
### New Window Creation
```applescript
tell application "iTerm"
set newWindow to (create window with default profile)
tell current session of newWindow
-- Execute command in new window
write text "cd /path && command"
end tell
return id of newWindow -- Returns window ID
end tell
```
### Window Title Setting
**Important:** iTerm2's AppleScript `window` object has a `title` property that is **read-only**.
To set the actual window title (OS title bar), use escape sequences:
```applescript
tell current session of newWindow
-- Set window title via escape sequence (OSC 2)
write text "printf '\\033]2;Team: Agent\\007'"
-- Optional: Set tab title via session name
set name to "Agent" -- This sets the tab title
end tell
```
### Escape Sequences Reference
- `\033]0;Title\007` - Set both icon name and window title
- `\033]1;Title\007` - Set tab title only (icon name)
- `\033]2;Title\007` - Set window title only
### Required iTerm2 Settings
- Settings > Profiles > Terminal > "Terminal may set tab/window title" must be enabled
- May need to disable shell auto-title in `.zshrc` or `.bashrc` to prevent overwriting
## WezTerm (Cross-Platform)
### New Window Creation
```bash
# Spawn new OS window
wezterm cli spawn --new-window --cwd /path -- env KEY=val command
# Returns pane ID, need to lookup window ID
```
### Window Title Setting
```bash
# Set window title by window ID
wezterm cli set-window-title --window-id 1 "Team: Agent"
# Or set tab title
wezterm cli set-tab-title "Agent"
```
### Getting Window ID
After spawning, we need to query for the window:
```bash
wezterm cli list --format json
# Returns array with pane_id, window_id, tab_id, etc.
```
## tmux (Skipped)
- `tmux new-window` creates windows within the same session
- True OS window creation requires spawning a new terminal process entirely
- Not supported per user request
## Zellij (Skipped)
- `zellij action new-tab` creates tabs within the same session
- No native support for creating OS windows
- Not supported per user request
## Universal Escape Sequences
All terminals supporting xterm escape sequences understand:
```bash
# Set window title (OSC 2)
printf '\033]2;My Window Title\007'
# Alternative syntax
printf '\e]2;My Window Title\a'
```
This is the most reliable cross-terminal method for setting window titles.
## Summary Table
| Feature | iTerm2 | WezTerm | tmux | Zellij |
| ---------------- | -------------- | ----------- | ---- | ------ |
| New OS Window | ✅ AppleScript | ✅ CLI | ❌ | ❌ |
| Set Window Title | ✅ Escape seq | ✅ CLI | N/A | N/A |
| Set Tab Title | ✅ AppleScript | ✅ CLI | N/A | N/A |
| Get Window ID | ✅ AppleScript | ✅ CLI list | N/A | N/A |
## Implementation Notes
1. **iTerm2:** Will use AppleScript for window creation and escape sequences for title setting
2. **WezTerm:** Will use CLI for both window creation and title setting
3. **Title Format:** `{teamName}: {agentName}` (e.g., "my-team: security-bot")
4. **Window Tracking:** Need to store window IDs separately from pane IDs for lifecycle management

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

5507
packages/companion-teams/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
{
"name": "companion-teams",
"version": "0.8.6",
"description": "Agent teams for companion, ported from claude-code-teams-mcp",
"repository": {
"type": "git",
"url": "git+https://github.com/burggraf/companion-teams.git"
},
"author": "Mark Burggraf",
"license": "MIT",
"keywords": [
"companion-package"
],
"scripts": {
"test": "vitest run"
},
"main": "extensions/index.ts",
"files": [
"extensions",
"skills",
"src",
"package.json",
"README.md"
],
"dependencies": {
"uuid": "^11.1.0"
},
"peerDependencies": {
"@mariozechner/companion-coding-agent": "*",
"@sinclair/typebox": "*"
},
"companion": {
"image": "https://raw.githubusercontent.com/burggraf/companion-teams/main/companion-team-in-action.png",
"extensions": [
"extensions/index.ts"
],
"skills": [
"skills"
]
},
"devDependencies": {
"@types/node": "^25.3.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View file

@ -0,0 +1,40 @@
# Progress Log: Separate Windows Mode Implementation
## 2026-02-26
### Completed
- [x] Researched terminal window title support for iTerm2, WezTerm, tmux, Zellij
- [x] Clarified requirements with user:
- True separate OS windows (not panes/tabs)
- Team lead also gets separate window
- Title format: `team-name: agent-name`
- iTerm2: use window title property via escape sequences
- Implementation: optional flag + global setting
- Skip tmux and Zellij for now
- [x] Created comprehensive task_plan.md with 10 phases
- [x] Created findings.md with technical research details
### Next Steps
1. ✅ Phase 1: Update Terminal Adapter Interface - COMPLETE
2. ✅ Phase 2: iTerm2 Window Support - COMPLETE
3. ✅ Phase 3: WezTerm Window Support - COMPLETE
4. ✅ Phase 4: Terminal Registry - COMPLETE
5. ✅ Phase 5: Team Configuration - COMPLETE
6. ✅ Phase 6: spawn_teammate Tool - COMPLETE
7. ✅ Phase 7: spawn_lead_window Tool - COMPLETE
8. ✅ Phase 8: Lifecycle Management (killTeammate, check_teammate updated) - COMPLETE
9. ✅ Phase 9: Testing - COMPLETE (all 8 tests pass, TypeScript compiles)
10. Phase 10: Documentation
### Blockers
None
### Decisions Made
- Use escape sequences (`\033]2;Title\007`) for iTerm2 window titles since AppleScript window.title is read-only
- Add new `windowId` field to Member model instead of reusing `tmuxPaneId`
- Store `separateWindows` global setting in TeamConfig
- Skip tmux/Zellij entirely (no fallback attempted)

View file

@ -0,0 +1,2 @@
npm publish --access public

View file

@ -0,0 +1,50 @@
---
description: Coordinate multiple agents working on a project using shared task lists and messaging via tmux or Zellij.
---
# Agent Teams
Coordinate multiple agents working on a project using shared task lists and messaging via **tmux** or **Zellij**.
## Workflow
1. **Create a team**: Use `team_create(team_name="my-team")`.
2. **Spawn teammates**: Use `spawn_teammate` to start additional agents. Give them specific roles and initial prompts.
3. **Manage tasks**:
- `task_create`: Define work for the team.
- `task_list`: List all tasks to monitor progress or find available work.
- `task_get`: Get full details of a specific task by ID.
- `task_update`: Update a task's status (`pending`, `in_progress`, `completed`, `deleted`) or owner.
4. **Communicate**: Use `send_message` to give instructions or receive updates. Teammates should use `read_inbox` to check for messages.
5. **Monitor**: Use `check_teammate` to see if they are still running and if they have sent messages back.
6. **Cleanup**:
- `force_kill_teammate`: Forcibly stop a teammate and remove them from the team.
- `process_shutdown_approved`: Orderly removal of a teammate after they've finished.
- `team_delete`: Remove a team and all its associated data.
## Teammate Instructions
When you are spawned as a teammate:
- Your status bar will show "Teammate: name @ team".
- You will automatically start by calling `read_inbox` to get your initial instructions.
- Regularly check `read_inbox` for updates from the lead.
- Use `send_message` to "team-lead" to report progress or ask questions.
- Update your assigned tasks using `task_update`.
- If you are idle for more than 30 seconds, you will automatically check your inbox for new messages.
## Best Practices for Teammates
- **Update Task Status**: As you work, use `task_update` to set your tasks to `in_progress` and then `completed`.
- **Frequent Communication**: Send short summaries of your work back to `team-lead` frequently.
- **Context Matters**: When you finish a task, send a message explaining your results and any new files you created.
- **Independence**: If you get stuck, try to solve it yourself first, but don't hesitate to ask `team-lead` for clarification.
- **Orderly Shutdown**: When you've finished all your work and have no more instructions, notify the lead and wait for shutdown approval.
## Best Practices for Team Leads
- **Clear Assignments**: Use `task_create` for all significant work items.
- **Contextual Prompts**: Provide enough context in `spawn_teammate` for the teammate to understand their specific role.
- **Task List Monitoring**: Regularly call `task_list` to see the status of all work.
- **Direct Feedback**: Use `send_message` to provide course corrections or new instructions to teammates.
- **Read Config**: Use `read_config` to see the full team roster and their current status.

View file

@ -0,0 +1,222 @@
/**
* CMUX Terminal Adapter
*
* Implements the TerminalAdapter interface for CMUX (cmux.dev).
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class CmuxAdapter implements TerminalAdapter {
readonly name = "cmux";
detect(): boolean {
// Check for CMUX specific environment variables
return !!process.env.CMUX_SOCKET_PATH || !!process.env.CMUX_WORKSPACE_ID;
}
spawn(options: SpawnOptions): string {
// We use new-split to create a new pane in CMUX.
// CMUX doesn't have a direct 'spawn' that returns a pane ID and runs a command
// in one go while also returning the ID in a way we can easily capture for 'isAlive'.
// However, 'new-split' returns the new surface ID.
// Construct the command with environment variables
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix
? `env ${envPrefix} ${options.command}`
: options.command;
// CMUX new-split returns "OK <UUID>"
const splitResult = execCommand("cmux", [
"new-split",
"right",
"--command",
fullCommand,
]);
if (splitResult.status !== 0) {
throw new Error(
`cmux new-split failed with status ${splitResult.status}: ${splitResult.stderr}`,
);
}
const output = splitResult.stdout.trim();
if (output.startsWith("OK ")) {
const surfaceId = output.substring(3).trim();
return surfaceId;
}
throw new Error(`cmux new-split returned unexpected output: ${output}`);
}
kill(paneId: string): void {
if (!paneId) return;
try {
// CMUX calls them surfaces
execCommand("cmux", ["close-surface", "--surface", paneId]);
} catch {
// Ignore errors during kill
}
}
isAlive(paneId: string): boolean {
if (!paneId) return false;
try {
// We can use list-pane-surfaces and grep for the ID
// Or just 'identify' if we want to be precise, but list-pane-surfaces is safer
const result = execCommand("cmux", ["list-pane-surfaces"]);
return result.stdout.includes(paneId);
} catch {
return false;
}
}
setTitle(title: string): void {
try {
// rename-tab or rename-workspace?
// Usually agents want to rename their current "tab" or "surface"
execCommand("cmux", ["rename-tab", title]);
} catch {
// Ignore errors
}
}
/**
* CMUX supports spawning separate OS windows
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window.
*/
spawnWindow(options: SpawnOptions): string {
// CMUX new-window returns "OK <UUID>"
const result = execCommand("cmux", ["new-window"]);
if (result.status !== 0) {
throw new Error(
`cmux new-window failed with status ${result.status}: ${result.stderr}`,
);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const windowId = output.substring(3).trim();
// Now we need to run the command in this window.
// Usually new-window creates a default workspace/surface.
// We might need to find the workspace in that window.
// For now, let's just use 'new-workspace' in that window if possible,
// but CMUX commands usually target the current window unless specified.
// Wait a bit for the window to be ready?
const envPrefix = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const fullCommand = envPrefix
? `env ${envPrefix} ${options.command}`
: options.command;
// Target the new window
execCommand("cmux", [
"new-workspace",
"--window",
windowId,
"--command",
fullCommand,
]);
if (options.teamName) {
this.setWindowTitle(windowId, options.teamName);
}
return windowId;
}
throw new Error(`cmux new-window returned unexpected output: ${output}`);
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
try {
execCommand("cmux", ["rename-window", "--window", windowId, title]);
} catch {
// Ignore
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId) return;
try {
execCommand("cmux", ["close-window", "--window", windowId]);
} catch {
// Ignore
}
}
/**
* Check if a window is still alive.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId) return false;
try {
const result = execCommand("cmux", ["list-windows"]);
return result.stdout.includes(windowId);
} catch {
return false;
}
}
/**
* Custom CMUX capability: create a workspace for a problem.
* This isn't part of the TerminalAdapter interface but can be used via the adapter.
*/
createProblemWorkspace(title: string, command?: string): string {
const args = ["new-workspace"];
if (command) {
args.push("--command", command);
}
const result = execCommand("cmux", args);
if (result.status !== 0) {
throw new Error(`cmux new-workspace failed: ${result.stderr}`);
}
const output = result.stdout.trim();
if (output.startsWith("OK ")) {
const workspaceId = output.substring(3).trim();
execCommand("cmux", [
"workspace-action",
"--action",
"rename",
"--title",
title,
"--workspace",
workspaceId,
]);
return workspaceId;
}
throw new Error(`cmux new-workspace returned unexpected output: ${output}`);
}
}

View file

@ -0,0 +1,320 @@
/**
* iTerm2 Terminal Adapter
*
* Implements the TerminalAdapter interface for iTerm2 terminal emulator.
* Uses AppleScript for all operations.
*/
import { spawnSync } from "node:child_process";
import type { SpawnOptions, TerminalAdapter } from "../utils/terminal-adapter";
/**
* Context needed for iTerm2 spawning (tracks last pane for layout)
*/
export interface Iterm2SpawnContext {
/** ID of the last spawned session, used for layout decisions */
lastSessionId?: string;
}
export class Iterm2Adapter implements TerminalAdapter {
readonly name = "iTerm2";
private spawnContext: Iterm2SpawnContext = {};
detect(): boolean {
return (
process.env.TERM_PROGRAM === "iTerm.app" &&
!process.env.TMUX &&
!process.env.ZELLIJ
);
}
/**
* Helper to execute AppleScript via stdin to avoid escaping issues with -e
*/
private runAppleScript(script: string): {
stdout: string;
stderr: string;
status: number | null;
} {
const result = spawnSync("osascript", ["-"], {
input: script,
encoding: "utf-8",
});
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
status: result.status,
};
}
spawn(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
let script: string;
if (!this.spawnContext.lastSessionId) {
script = `tell application "iTerm2"
tell current session of current window
set newSession to split vertically with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end tell`;
} else {
script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${this.spawnContext.lastSessionId}" then
tell aSession
set newSession to split horizontally with default profile
tell newSession
write text "${escapedCmd}"
return id
end tell
end tell
end if
end repeat
end repeat
end repeat
end tell`;
}
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(
`osascript failed with status ${result.status}: ${result.stderr}`,
);
}
const sessionId = result.stdout.toString().trim();
this.spawnContext.lastSessionId = sessionId;
return `iterm_${sessionId}`;
}
kill(paneId: string): void {
if (
!paneId ||
!paneId.startsWith("iterm_") ||
paneId.startsWith("iterm_win_")
) {
return;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
close aSession
return "Closed"
end if
end repeat
end repeat
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
isAlive(paneId: string): boolean {
if (
!paneId ||
!paneId.startsWith("iterm_") ||
paneId.startsWith("iterm_win_")
) {
return false;
}
const itermId = paneId.replace("iterm_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
repeat with aTab in tabs of aWindow
repeat with aSession in sessions of aTab
if id of aSession is "${itermId}" then
return "Alive"
end if
end repeat
end repeat
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
setTitle(title: string): void {
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2" to tell current session of current window
set name to "${escapedTitle}"
end tell`;
try {
this.runAppleScript(script);
} catch {
// Ignore errors
}
}
/**
* iTerm2 supports spawning separate OS windows via AppleScript
*/
supportsWindows(): boolean {
return true;
}
/**
* Spawn a new separate OS window with the given options.
*/
spawnWindow(options: SpawnOptions): string {
const envStr = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`)
.join(" ");
const itermCmd = `cd '${options.cwd}' && ${envStr} ${options.command}`;
const escapedCmd = itermCmd.replace(/"/g, '\\"');
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
const escapedTitle = windowTitle.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
set newWindow to (create window with default profile)
tell current session of newWindow
-- Set the session name (tab title)
set name to "${escapedTitle}"
-- Set window title via escape sequence (OSC 2)
-- We use double backslashes for AppleScript to emit a single backslash to the shell
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
-- Execute the command
write text "cd '${options.cwd}' && ${escapedCmd}"
return id of newWindow
end tell
end tell`;
const result = this.runAppleScript(script);
if (result.status !== 0) {
throw new Error(
`osascript failed with status ${result.status}: ${result.stderr}`,
);
}
const windowId = result.stdout.toString().trim();
return `iterm_win_${windowId}`;
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const escapedTitle = title.replace(/"/g, '\\"');
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
tell current session of aWindow
write text "printf '\\\\033]2;${escapedTitle}\\\\007'"
end tell
exit repeat
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
close aWindow
return "Closed"
end if
end repeat
end tell`;
try {
this.runAppleScript(script);
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId || !windowId.startsWith("iterm_win_")) {
return false;
}
const itermId = windowId.replace("iterm_win_", "");
const script = `tell application "iTerm2"
repeat with aWindow in windows
if id of aWindow is "${itermId}" then
return "Alive"
end if
end repeat
end tell`;
try {
const result = this.runAppleScript(script);
return result.stdout.includes("Alive");
} catch {
return false;
}
}
/**
* Set the spawn context (used to restore state when needed)
*/
setSpawnContext(context: Iterm2SpawnContext): void {
this.spawnContext = context;
}
/**
* Get current spawn context (useful for persisting state)
*/
getSpawnContext(): Iterm2SpawnContext {
return { ...this.spawnContext };
}
}

View file

@ -0,0 +1,123 @@
/**
* Terminal Registry
*
* Manages terminal adapters and provides automatic selection based on
* the current environment.
*/
import type { TerminalAdapter } from "../utils/terminal-adapter";
import { CmuxAdapter } from "./cmux-adapter";
import { Iterm2Adapter } from "./iterm2-adapter";
import { TmuxAdapter } from "./tmux-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
import { ZellijAdapter } from "./zellij-adapter";
/**
* Available terminal adapters, ordered by priority
*
* Detection order (first match wins):
* 0. CMUX - if CMUX_SOCKET_PATH is set
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*/
const adapters: TerminalAdapter[] = [
new CmuxAdapter(),
new TmuxAdapter(),
new ZellijAdapter(),
new Iterm2Adapter(),
new WezTermAdapter(),
];
/**
* Cached detected adapter
*/
let cachedAdapter: TerminalAdapter | null = null;
/**
* Detect and return the appropriate terminal adapter for the current environment.
*
* Detection order (first match wins):
* 1. tmux - if TMUX env is set
* 2. Zellij - if ZELLIJ env is set and not in tmux
* 3. iTerm2 - if TERM_PROGRAM=iTerm.app and not in tmux/zellij
* 4. WezTerm - if WEZTERM_PANE env is set and not in tmux/zellij
*
* @returns The detected terminal adapter, or null if none detected
*/
export function getTerminalAdapter(): TerminalAdapter | null {
if (cachedAdapter) {
return cachedAdapter;
}
for (const adapter of adapters) {
if (adapter.detect()) {
cachedAdapter = adapter;
return adapter;
}
}
return null;
}
/**
* Get a specific terminal adapter by name.
*
* @param name - The adapter name (e.g., "tmux", "iTerm2", "zellij", "WezTerm")
* @returns The adapter instance, or undefined if not found
*/
export function getAdapterByName(name: string): TerminalAdapter | undefined {
return adapters.find((a) => a.name === name);
}
/**
* Get all available adapters.
*
* @returns Array of all registered adapters
*/
export function getAllAdapters(): TerminalAdapter[] {
return [...adapters];
}
/**
* Clear the cached adapter (useful for testing or environment changes)
*/
export function clearAdapterCache(): void {
cachedAdapter = null;
}
/**
* Set a specific adapter (useful for testing or forced selection)
*/
export function setAdapter(adapter: TerminalAdapter): void {
cachedAdapter = adapter;
}
/**
* Check if any terminal adapter is available.
*
* @returns true if a terminal adapter was detected
*/
export function hasTerminalAdapter(): boolean {
return getTerminalAdapter() !== null;
}
/**
* Check if the current terminal supports spawning separate OS windows.
*
* @returns true if the detected terminal supports windows (iTerm2, WezTerm)
*/
export function supportsWindows(): boolean {
const adapter = getTerminalAdapter();
return adapter?.supportsWindows() ?? false;
}
/**
* Get the name of the currently detected terminal adapter.
*
* @returns The adapter name, or null if none detected
*/
export function getTerminalName(): string | null {
return getTerminalAdapter()?.name ?? null;
}

View file

@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as terminalAdapter from "../utils/terminal-adapter";
import { TmuxAdapter } from "./tmux-adapter";
describe("TmuxAdapter", () => {
let adapter: TmuxAdapter;
let mockExecCommand: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
adapter = new TmuxAdapter();
mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
delete process.env.TMUX;
delete process.env.ZELLIJ;
delete process.env.WEZTERM_PANE;
delete process.env.TERM_PROGRAM;
delete process.env.COLORTERM;
});
afterEach(() => {
vi.clearAllMocks();
});
it("detects tmux in headless runtimes when the binary is available", () => {
mockExecCommand.mockReturnValue({
stdout: "tmux 3.4",
stderr: "",
status: 0,
});
expect(adapter.detect()).toBe(true);
expect(mockExecCommand).toHaveBeenCalledWith("tmux", ["-V"]);
});
it("does not detect tmux in GUI terminals just because the binary exists", () => {
process.env.COLORTERM = "truecolor";
mockExecCommand.mockReturnValue({
stdout: "tmux 3.4",
stderr: "",
status: 0,
});
expect(adapter.detect()).toBe(false);
expect(mockExecCommand).not.toHaveBeenCalled();
});
it("creates a detached team session when not already inside tmux", () => {
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === "has-session") {
return { stdout: "", stderr: "missing", status: 1 };
}
if (args[0] === "new-session") {
return { stdout: "%1\n", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
expect(
adapter.spawn({
name: "worker",
cwd: "/tmp/project",
command: "companion",
env: { COMPANION_TEAM_NAME: "demo", COMPANION_AGENT_NAME: "worker" },
}),
).toBe("%1");
expect(mockExecCommand).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["new-session", "-d", "-s", "companion-teams-demo"]),
);
});
it("splits an existing detached session when not already inside tmux", () => {
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args[0] === "has-session") {
return { stdout: "", stderr: "", status: 0 };
}
if (args[0] === "split-window") {
return { stdout: "%2\n", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
expect(
adapter.spawn({
name: "worker",
cwd: "/tmp/project",
command: "companion",
env: { COMPANION_TEAM_NAME: "demo", COMPANION_AGENT_NAME: "worker" },
}),
).toBe("%2");
expect(mockExecCommand).toHaveBeenCalledWith(
"tmux",
expect.arrayContaining(["split-window", "-t", "companion-teams-demo:0"]),
);
});
it("checks pane liveness by pane id", () => {
mockExecCommand.mockReturnValue({
stdout: "%1\n%7\n",
stderr: "",
status: 0,
});
expect(adapter.isAlive("%7")).toBe(true);
expect(mockExecCommand).toHaveBeenCalledWith("tmux", [
"list-panes",
"-a",
"-F",
"#{pane_id}",
]);
});
});

View file

@ -0,0 +1,190 @@
/**
* Tmux Terminal Adapter
*
* Implements the TerminalAdapter interface for tmux terminal multiplexer.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class TmuxAdapter implements TerminalAdapter {
readonly name = "tmux";
detect(): boolean {
if (process.env.TMUX) return true;
if (process.env.ZELLIJ || process.env.TERM_PROGRAM === "iTerm.app") {
return false;
}
if (process.env.TERM_PROGRAM || process.env.COLORTERM) return false;
if (process.env.WEZTERM_PANE) return false;
return execCommand("tmux", ["-V"]).status === 0;
}
spawn(options: SpawnOptions): string {
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`);
let targetWindow: string | null = null;
if (!process.env.TMUX) {
const sessionName = `companion-teams-${options.env.COMPANION_TEAM_NAME || "default"}`;
targetWindow = `${sessionName}:0`;
const hasSession = execCommand("tmux", [
"has-session",
"-t",
sessionName,
]);
if (hasSession.status !== 0) {
const result = execCommand("tmux", [
"new-session",
"-d",
"-s",
sessionName,
"-P",
"-F",
"#{pane_id}",
"-c",
options.cwd,
"env",
...envArgs,
"sh",
"-c",
options.command,
]);
if (result.status !== 0) {
throw new Error(
`tmux spawn failed with status ${result.status}: ${result.stderr}`,
);
}
// The first pane becomes window 0; layout only matters once later spawns split it.
return result.stdout.trim();
}
}
const tmuxArgs = [
"split-window",
"-h",
"-dP",
"-F",
"#{pane_id}",
...(targetWindow ? ["-t", targetWindow] : []),
"-c",
options.cwd,
"env",
...envArgs,
"sh",
"-c",
options.command,
];
const result = execCommand("tmux", tmuxArgs);
if (result.status !== 0) {
throw new Error(
`tmux spawn failed with status ${result.status}: ${result.stderr}`,
);
}
// Apply layout after spawning
execCommand("tmux", [
"set-window-option",
...(targetWindow ? ["-t", targetWindow] : []),
"main-pane-width",
"60%",
]);
execCommand("tmux", [
"select-layout",
...(targetWindow ? ["-t", targetWindow] : []),
"main-vertical",
]);
return result.stdout.trim();
}
kill(paneId: string): void {
if (
!paneId ||
paneId.startsWith("iterm_") ||
paneId.startsWith("zellij_")
) {
return; // Not a tmux pane
}
try {
execCommand("tmux", ["kill-pane", "-t", paneId.trim()]);
} catch {
// Ignore errors - pane may already be dead
}
}
isAlive(paneId: string): boolean {
if (
!paneId ||
paneId.startsWith("iterm_") ||
paneId.startsWith("zellij_")
) {
return false; // Not a tmux pane
}
const result = execCommand("tmux", [
"list-panes",
"-a",
"-F",
"#{pane_id}",
]);
return (
result.status === 0 &&
result.stdout.split("\n").some((line) => line.trim() === paneId.trim())
);
}
setTitle(title: string): void {
try {
execCommand("tmux", ["select-pane", "-T", title]);
} catch {
// Ignore errors
}
}
/**
* tmux does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error(
"tmux does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.",
);
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}

View file

@ -0,0 +1,122 @@
/**
* WezTerm Adapter Tests
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as terminalAdapter from "../utils/terminal-adapter";
import { WezTermAdapter } from "./wezterm-adapter";
describe("WezTermAdapter", () => {
let adapter: WezTermAdapter;
let mockExecCommand: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
adapter = new WezTermAdapter();
mockExecCommand = vi.spyOn(terminalAdapter, "execCommand");
delete process.env.WEZTERM_PANE;
delete process.env.TMUX;
delete process.env.ZELLIJ;
process.env.WEZTERM_PANE = "0";
});
afterEach(() => {
vi.clearAllMocks();
});
describe("name", () => {
it("should have the correct name", () => {
expect(adapter.name).toBe("WezTerm");
});
});
describe("detect", () => {
it("should detect when WEZTERM_PANE is set", () => {
mockExecCommand.mockReturnValue({
stdout: "version 1.0",
stderr: "",
status: 0,
});
expect(adapter.detect()).toBe(true);
});
});
describe("spawn", () => {
it("should spawn first pane to the right with 50%", () => {
// Mock getPanes finding only current pane
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]),
stderr: "",
status: 0,
};
}
if (args.includes("split-pane")) {
return { stdout: "1", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "test-agent",
cwd: "/home/user/project",
command: "companion --agent test",
env: { COMPANION_AGENT_ID: "test-123" },
});
expect(result).toBe("wezterm_1");
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining([
"cli",
"split-pane",
"--right",
"--percent",
"50",
]),
);
});
it("should spawn subsequent panes by splitting the sidebar", () => {
// Mock getPanes finding current pane (0) and sidebar pane (1)
mockExecCommand.mockImplementation((_bin: string, args: string[]) => {
if (args.includes("list")) {
return {
stdout: JSON.stringify([
{ pane_id: 0, tab_id: 0 },
{ pane_id: 1, tab_id: 0 },
]),
stderr: "",
status: 0,
};
}
if (args.includes("split-pane")) {
return { stdout: "2", stderr: "", status: 0 };
}
return { stdout: "", stderr: "", status: 0 };
});
const result = adapter.spawn({
name: "agent2",
cwd: "/home/user/project",
command: "companion",
env: {},
});
expect(result).toBe("wezterm_2");
// 1 sidebar pane already exists, so percent should be floor(100/(1+1)) = 50%
expect(mockExecCommand).toHaveBeenCalledWith(
expect.stringContaining("wezterm"),
expect.arrayContaining([
"cli",
"split-pane",
"--bottom",
"--pane-id",
"1",
"--percent",
"50",
]),
);
});
});
});

View file

@ -0,0 +1,366 @@
/**
* WezTerm Terminal Adapter
*
* Implements the TerminalAdapter interface for WezTerm terminal emulator.
* Uses wezterm cli split-pane for pane management.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class WezTermAdapter implements TerminalAdapter {
readonly name = "WezTerm";
// Common paths where wezterm CLI might be found
private possiblePaths = [
"wezterm", // In PATH
"/Applications/WezTerm.app/Contents/MacOS/wezterm", // macOS
"/usr/local/bin/wezterm", // Linux/macOS common
"/usr/bin/wezterm", // Linux system
];
private weztermPath: string | null = null;
private findWeztermBinary(): string | null {
if (this.weztermPath !== null) {
return this.weztermPath;
}
for (const path of this.possiblePaths) {
try {
const result = execCommand(path, ["--version"]);
if (result.status === 0) {
this.weztermPath = path;
return path;
}
} catch {
// Continue to next path
}
}
this.weztermPath = null;
return null;
}
detect(): boolean {
if (!process.env.WEZTERM_PANE || process.env.TMUX || process.env.ZELLIJ) {
return false;
}
return this.findWeztermBinary() !== null;
}
/**
* Get all panes in the current tab to determine layout state.
*/
private getPanes(): any[] {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return [];
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return [];
try {
const allPanes = JSON.parse(result.stdout);
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
// Find the tab of the current pane
const currentPane = allPanes.find(
(p: any) => p.pane_id === currentPaneId,
);
if (!currentPane) return [];
// Return all panes in the same tab
return allPanes.filter((p: any) => p.tab_id === currentPane.tab_id);
} catch {
return [];
}
}
spawn(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const panes = this.getPanes();
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`);
let weztermArgs: string[];
// First pane: split to the right with 50% (matches iTerm2/tmux behavior)
const isFirstPane = panes.length === 1;
if (isFirstPane) {
weztermArgs = [
"cli",
"split-pane",
"--right",
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
} else {
// Subsequent teammates stack in the sidebar on the right.
// currentPaneId (id 0) is the main pane on the left.
// All other panes are in the sidebar.
const currentPaneId = parseInt(process.env.WEZTERM_PANE || "0", 10);
const sidebarPanes = panes
.filter((p) => p.pane_id !== currentPaneId)
.sort((a, b) => b.cursor_y - a.cursor_y); // Sort by vertical position (bottom-most first)
// To add a new pane to the bottom of the sidebar stack:
// We always split the BOTTOM-MOST pane (sidebarPanes[0])
// and use 50% so the new pane and the previous bottom pane are equal.
// This progressively fills the sidebar from top to bottom.
const targetPane = sidebarPanes[0];
weztermArgs = [
"cli",
"split-pane",
"--bottom",
"--pane-id",
targetPane.pane_id.toString(),
"--percent",
"50",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
}
const result = execCommand(weztermBin, weztermArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn failed: ${result.stderr}`);
}
// New: After spawning, tell WezTerm to equalize the panes in this tab
// This ensures that regardless of the split math, they all end up the same height.
try {
execCommand(weztermBin, ["cli", "zoom-pane", "--unzoom"]); // Ensure not zoomed
// WezTerm doesn't have a single "equalize" command like tmux,
// but splitting with no percentage usually balances, or we can use
// the 'AdjustPaneSize' sequence.
// For now, let's stick to the 50/50 split of the LAST pane which is most reliable.
} catch {}
const paneId = result.stdout.trim();
return `wezterm_${paneId}`;
}
kill(paneId: string): void {
if (!paneId?.startsWith("wezterm_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermId = paneId.replace("wezterm_", "");
try {
execCommand(weztermBin, ["cli", "kill-pane", "--pane-id", weztermId]);
} catch {}
}
isAlive(paneId: string): boolean {
if (!paneId?.startsWith("wezterm_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermId = parseInt(paneId.replace("wezterm_", ""), 10);
const panes = this.getPanes();
return panes.some((p) => p.pane_id === weztermId);
}
setTitle(title: string): void {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
try {
execCommand(weztermBin, ["cli", "set-tab-title", title]);
} catch {}
}
/**
* WezTerm supports spawning separate OS windows via CLI
*/
supportsWindows(): boolean {
return this.findWeztermBinary() !== null;
}
/**
* Spawn a new separate OS window with the given options.
* Uses `wezterm cli spawn --new-window` and sets the window title.
*/
spawnWindow(options: SpawnOptions): string {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) {
throw new Error("WezTerm CLI binary not found.");
}
const envArgs = Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`);
// Format window title as "teamName: agentName" if teamName is provided
const windowTitle = options.teamName
? `${options.teamName}: ${options.name}`
: options.name;
// Spawn a new window
const spawnArgs = [
"cli",
"spawn",
"--new-window",
"--cwd",
options.cwd,
"--",
"env",
...envArgs,
"sh",
"-c",
options.command,
];
const result = execCommand(weztermBin, spawnArgs);
if (result.status !== 0) {
throw new Error(`wezterm spawn-window failed: ${result.stderr}`);
}
// The output is the pane ID, we need to find the window ID
const paneId = result.stdout.trim();
// Query to get window ID from pane ID
const windowId = this.getWindowIdFromPaneId(parseInt(paneId, 10));
// Set the window title if we found the window
if (windowId !== null) {
this.setWindowTitle(`wezterm_win_${windowId}`, windowTitle);
}
return `wezterm_win_${windowId || paneId}`;
}
/**
* Get window ID from a pane ID by querying WezTerm
*/
private getWindowIdFromPaneId(paneId: number): number | null {
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return null;
const result = execCommand(weztermBin, ["cli", "list", "--format", "json"]);
if (result.status !== 0) return null;
try {
const allPanes = JSON.parse(result.stdout);
const pane = allPanes.find((p: any) => p.pane_id === paneId);
return pane?.window_id ?? null;
} catch {
return null;
}
}
/**
* Set the title of a specific window.
*/
setWindowTitle(windowId: string, title: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
execCommand(weztermBin, [
"cli",
"set-window-title",
"--window-id",
weztermWindowId,
title,
]);
} catch {
// Silently fail
}
}
/**
* Kill/terminate a window.
*/
killWindow(windowId: string): void {
if (!windowId?.startsWith("wezterm_win_")) return;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
// WezTerm doesn't have a direct kill-window command, so we kill all panes in the window
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return;
const allPanes = JSON.parse(result.stdout);
const windowPanes = allPanes.filter(
(p: any) => p.window_id.toString() === weztermWindowId,
);
for (const pane of windowPanes) {
execCommand(weztermBin, [
"cli",
"kill-pane",
"--pane-id",
pane.pane_id.toString(),
]);
}
} catch {
// Silently fail
}
}
/**
* Check if a window is still alive/active.
*/
isWindowAlive(windowId: string): boolean {
if (!windowId?.startsWith("wezterm_win_")) return false;
const weztermBin = this.findWeztermBinary();
if (!weztermBin) return false;
const weztermWindowId = windowId.replace("wezterm_win_", "");
try {
const result = execCommand(weztermBin, [
"cli",
"list",
"--format",
"json",
]);
if (result.status !== 0) return false;
const allPanes = JSON.parse(result.stdout);
return allPanes.some(
(p: any) => p.window_id.toString() === weztermWindowId,
);
} catch {
return false;
}
}
}

View file

@ -0,0 +1,109 @@
/**
* Zellij Terminal Adapter
*
* Implements the TerminalAdapter interface for Zellij terminal multiplexer.
* Note: Zellij uses --close-on-exit, so explicit kill is not needed.
*/
import {
execCommand,
type SpawnOptions,
type TerminalAdapter,
} from "../utils/terminal-adapter";
export class ZellijAdapter implements TerminalAdapter {
readonly name = "zellij";
detect(): boolean {
// Zellij is available if ZELLIJ env is set and not in tmux
return !!process.env.ZELLIJ && !process.env.TMUX;
}
spawn(options: SpawnOptions): string {
const zellijArgs = [
"run",
"--name",
options.name,
"--cwd",
options.cwd,
"--close-on-exit",
"--",
"env",
...Object.entries(options.env)
.filter(([k]) => k.startsWith("COMPANION_"))
.map(([k, v]) => `${k}=${v}`),
"sh",
"-c",
options.command,
];
const result = execCommand("zellij", zellijArgs);
if (result.status !== 0) {
throw new Error(
`zellij spawn failed with status ${result.status}: ${result.stderr}`,
);
}
// Zellij doesn't return a pane ID, so we create a synthetic one
return `zellij_${options.name}`;
}
kill(_paneId: string): void {
// Zellij uses --close-on-exit, so panes close automatically
// when the process exits. No explicit kill needed.
}
isAlive(paneId: string): boolean {
// Zellij doesn't have a straightforward way to check if a pane is alive
// For now, we assume alive if it's a zellij pane ID
if (!paneId || !paneId.startsWith("zellij_")) {
return false;
}
// Could potentially use `zellij list-sessions` or similar in the future
return true;
}
setTitle(_title: string): void {
// Zellij pane titles are set via --name at spawn time
// No runtime title changing supported
}
/**
* Zellij does not support spawning separate OS windows
*/
supportsWindows(): boolean {
return false;
}
/**
* Not supported - throws error
*/
spawnWindow(_options: SpawnOptions): string {
throw new Error(
"Zellij does not support spawning separate OS windows. Use iTerm2 or WezTerm instead.",
);
}
/**
* Not supported - no-op
*/
setWindowTitle(_windowId: string, _title: string): void {
// Not supported
}
/**
* Not supported - no-op
*/
killWindow(_windowId: string): void {
// Not supported
}
/**
* Not supported - always returns false
*/
isWindowAlive(_windowId: string): boolean {
return false;
}
}

View file

@ -0,0 +1,79 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { runHook } from "./hooks";
describe("runHook", () => {
const hooksDir = path.join(process.cwd(), ".companion", "team-hooks");
beforeAll(() => {
if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true });
}
});
afterAll(() => {
// Optional: Clean up created scripts
const files = ["success_hook.sh", "fail_hook.sh"];
files.forEach((f) => {
const p = path.join(hooksDir, f);
if (fs.existsSync(p)) fs.unlinkSync(p);
});
});
it("should return true if hook script does not exist", async () => {
const result = await runHook("test_team", "non_existent_hook", {
data: "test",
});
expect(result).toBe(true);
});
it("should return true if hook script succeeds", async () => {
const hookName = "success_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
// Create a simple script that exits with 0
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 0", { mode: 0o755 });
const result = await runHook("test_team", hookName, { data: "test" });
expect(result).toBe(true);
});
it("should return false if hook script fails", async () => {
const hookName = "fail_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
// Create a simple script that exits with 1
fs.writeFileSync(scriptPath, "#!/bin/bash\nexit 1", { mode: 0o755 });
// Mock console.error to avoid noise in test output
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await runHook("test_team", hookName, { data: "test" });
expect(result).toBe(false);
consoleSpy.mockRestore();
});
it("should pass the payload to the hook script", async () => {
const hookName = "payload_hook";
const scriptPath = path.join(hooksDir, `${hookName}.sh`);
const outputFile = path.join(hooksDir, "payload_output.txt");
// Create a script that writes its first argument to a file
fs.writeFileSync(scriptPath, `#!/bin/bash\necho "$1" > "${outputFile}"`, {
mode: 0o755,
});
const payload = { key: "value", "special'char": true };
const result = await runHook("test_team", hookName, payload);
expect(result).toBe(true);
const output = fs.readFileSync(outputFile, "utf-8").trim();
expect(JSON.parse(output)).toEqual(payload);
// Clean up
fs.unlinkSync(scriptPath);
if (fs.existsSync(outputFile)) fs.unlinkSync(outputFile);
});
});

View file

@ -0,0 +1,44 @@
import { execFile } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Runs a hook script asynchronously if it exists.
* Hooks are located in .companion/team-hooks/{hookName}.sh relative to the CWD.
*
* @param teamName The name of the team.
* @param hookName The name of the hook to run (e.g., 'task_completed').
* @param payload The payload to pass to the hook script as the first argument.
* @returns true if the hook doesn't exist or executes successfully; false otherwise.
*/
export async function runHook(
teamName: string,
hookName: string,
payload: any,
): Promise<boolean> {
const hookPath = path.join(
process.cwd(),
".companion",
"team-hooks",
`${hookName}.sh`,
);
if (!fs.existsSync(hookPath)) {
return true;
}
try {
const payloadStr = JSON.stringify(payload);
// Use execFile: More secure (no shell interpolation) and asynchronous
await execFileAsync(hookPath, [payloadStr], {
env: { ...process.env, COMPANION_TEAM: teamName },
});
return true;
} catch (error) {
console.error(`Hook ${hookName} failed:`, error);
return false;
}
}

View file

@ -0,0 +1,46 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { withLock } from "./lock";
describe("withLock race conditions", () => {
const testDir = path.join(os.tmpdir(), `companion-lock-race-test-${Date.now()}`);
const lockPath = path.join(testDir, "test");
beforeEach(() => {
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should handle multiple concurrent attempts to acquire the lock", async () => {
let counter = 0;
const iterations = 20;
const concurrentCount = 5;
const runTask = async () => {
for (let i = 0; i < iterations; i++) {
await withLock(lockPath, async () => {
const current = counter;
// Add a small delay to increase the chance of race conditions if locking fails
await new Promise((resolve) =>
setTimeout(resolve, Math.random() * 10),
);
counter = current + 1;
});
}
};
const promises = [];
for (let i = 0; i < concurrentCount; i++) {
promises.push(runTask());
}
await Promise.all(promises);
expect(counter).toBe(iterations * concurrentCount);
});
});

View file

@ -0,0 +1,51 @@
// Project: companion-teams
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withLock } from "./lock";
describe("withLock", () => {
const testDir = path.join(os.tmpdir(), `companion-lock-test-${Date.now()}`);
const lockPath = path.join(testDir, "test");
const lockFile = `${lockPath}.lock`;
beforeEach(() => {
if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should successfully acquire and release the lock", async () => {
const fn = vi.fn().mockResolvedValue("result");
const result = await withLock(lockPath, fn);
expect(result).toBe("result");
expect(fn).toHaveBeenCalled();
expect(fs.existsSync(lockFile)).toBe(false);
});
it("should fail to acquire lock if already held", async () => {
// Manually create lock file
fs.writeFileSync(lockFile, "9999");
const fn = vi.fn().mockResolvedValue("result");
// Test with only 2 retries to speed up the failure
await expect(withLock(lockPath, fn, 2)).rejects.toThrow(
"Could not acquire lock",
);
expect(fn).not.toHaveBeenCalled();
});
it("should release lock even if function fails", async () => {
const fn = vi.fn().mockRejectedValue(new Error("failure"));
await expect(withLock(lockPath, fn)).rejects.toThrow("failure");
expect(fs.existsSync(lockFile)).toBe(false);
});
});

View file

@ -0,0 +1,50 @@
// Project: companion-teams
import fs from "node:fs";
const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale
export async function withLock<T>(
lockPath: string,
fn: () => Promise<T>,
retries: number = 50,
): Promise<T> {
const lockFile = `${lockPath}.lock`;
while (retries > 0) {
try {
// Check if lock exists and is stale
if (fs.existsSync(lockFile)) {
const stats = fs.statSync(lockFile);
const age = Date.now() - stats.mtimeMs;
if (age > STALE_LOCK_TIMEOUT) {
// Attempt to remove stale lock
try {
fs.unlinkSync(lockFile);
} catch (_error) {
// ignore, another process might have already removed it
}
}
}
fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" });
break;
} catch (_error) {
retries--;
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
if (retries === 0) {
throw new Error("Could not acquire lock");
}
try {
return await fn();
} finally {
try {
fs.unlinkSync(lockFile);
} catch (_error) {
// ignore
}
}
}

View file

@ -0,0 +1,130 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
appendMessage,
broadcastMessage,
readInbox,
sendPlainMessage,
} from "./messaging";
import * as paths from "./paths";
// Mock the paths to use a temporary directory
const testDir = path.join(os.tmpdir(), `companion-teams-test-${Date.now()}`);
describe("Messaging Utilities", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
// Override paths to use testDir
vi.spyOn(paths, "inboxPath").mockImplementation((_teamName, agentName) => {
return path.join(testDir, "inboxes", `${agentName}.json`);
});
vi.spyOn(paths, "teamDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockImplementation((_teamName) => {
return path.join(testDir, "config.json");
});
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should append a message successfully", async () => {
const msg = {
from: "sender",
text: "hello",
timestamp: "now",
read: false,
};
await appendMessage("test-team", "receiver", msg);
const inbox = await readInbox("test-team", "receiver", false, false);
expect(inbox.length).toBe(1);
expect(inbox[0].text).toBe("hello");
});
it("should handle concurrent appends (Stress Test)", async () => {
const numMessages = 100;
const promises = [];
for (let i = 0; i < numMessages; i++) {
promises.push(
sendPlainMessage(
"test-team",
`sender-${i}`,
"receiver",
`msg-${i}`,
`summary-${i}`,
),
);
}
await Promise.all(promises);
const inbox = await readInbox("test-team", "receiver", false, false);
expect(inbox.length).toBe(numMessages);
// Verify all messages are present
const texts = inbox.map((m) => m.text).sort();
for (let i = 0; i < numMessages; i++) {
expect(texts).toContain(`msg-${i}`);
}
});
it("should mark messages as read", async () => {
await sendPlainMessage(
"test-team",
"sender",
"receiver",
"msg1",
"summary1",
);
await sendPlainMessage(
"test-team",
"sender",
"receiver",
"msg2",
"summary2",
);
// Read only unread messages
const unread = await readInbox("test-team", "receiver", true, true);
expect(unread.length).toBe(2);
// Now all should be read
const all = await readInbox("test-team", "receiver", false, false);
expect(all.length).toBe(2);
expect(all.every((m) => m.read)).toBe(true);
});
it("should broadcast message to all members except the sender", async () => {
// Setup team config
const config = {
name: "test-team",
members: [{ name: "sender" }, { name: "member1" }, { name: "member2" }],
};
const configFilePath = path.join(testDir, "config.json");
fs.writeFileSync(configFilePath, JSON.stringify(config));
await broadcastMessage("test-team", "sender", "broadcast text", "summary");
// Check member1's inbox
const inbox1 = await readInbox("test-team", "member1", false, false);
expect(inbox1.length).toBe(1);
expect(inbox1[0].text).toBe("broadcast text");
expect(inbox1[0].from).toBe("sender");
// Check member2's inbox
const inbox2 = await readInbox("test-team", "member2", false, false);
expect(inbox2.length).toBe(1);
expect(inbox2[0].text).toBe("broadcast text");
expect(inbox2[0].from).toBe("sender");
// Check sender's inbox (should be empty)
const inboxSender = await readInbox("test-team", "sender", false, false);
expect(inboxSender.length).toBe(0);
});
});

View file

@ -0,0 +1,120 @@
import fs from "node:fs";
import path from "node:path";
import { withLock } from "./lock";
import type { InboxMessage } from "./models";
import { inboxPath } from "./paths";
import { readConfig } from "./teams";
export function nowIso(): string {
return new Date().toISOString();
}
export async function appendMessage(
teamName: string,
agentName: string,
message: InboxMessage,
) {
const p = inboxPath(teamName, agentName);
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
await withLock(p, async () => {
let msgs: InboxMessage[] = [];
if (fs.existsSync(p)) {
msgs = JSON.parse(fs.readFileSync(p, "utf-8"));
}
msgs.push(message);
fs.writeFileSync(p, JSON.stringify(msgs, null, 2));
});
}
export async function readInbox(
teamName: string,
agentName: string,
unreadOnly = false,
markAsRead = true,
): Promise<InboxMessage[]> {
const p = inboxPath(teamName, agentName);
if (!fs.existsSync(p)) return [];
return await withLock(p, async () => {
const allMsgs: InboxMessage[] = JSON.parse(fs.readFileSync(p, "utf-8"));
let result = allMsgs;
if (unreadOnly) {
result = allMsgs.filter((m) => !m.read);
}
if (markAsRead && result.length > 0) {
for (const m of allMsgs) {
if (result.includes(m)) {
m.read = true;
}
}
fs.writeFileSync(p, JSON.stringify(allMsgs, null, 2));
}
return result;
});
}
export async function sendPlainMessage(
teamName: string,
fromName: string,
toName: string,
text: string,
summary: string,
color?: string,
) {
const msg: InboxMessage = {
from: fromName,
text,
timestamp: nowIso(),
read: false,
summary,
color,
};
await appendMessage(teamName, toName, msg);
}
/**
* Broadcasts a message to all team members except the sender.
* @param teamName The name of the team
* @param fromName The name of the sender
* @param text The message text
* @param summary A short summary of the message
* @param color An optional color for the message
*/
export async function broadcastMessage(
teamName: string,
fromName: string,
text: string,
summary: string,
color?: string,
) {
const config = await readConfig(teamName);
// Create an array of delivery promises for all members except the sender
const deliveryPromises = config.members
.filter((member) => member.name !== fromName)
.map((member) =>
sendPlainMessage(teamName, fromName, member.name, text, summary, color),
);
// Execute deliveries in parallel and wait for all to settle
const results = await Promise.allSettled(deliveryPromises);
// Log failures for diagnostics
const failures = results.filter(
(r): r is PromiseRejectedResult => r.status === "rejected",
);
if (failures.length > 0) {
console.error(
`Broadcast partially failed: ${failures.length} messages could not be delivered.`,
);
// Optionally log individual errors
for (const failure of failures) {
console.error("- Delivery error:", failure.reason);
}
}
}

View file

@ -0,0 +1,51 @@
export interface Member {
agentId: string;
name: string;
agentType: string;
model?: string;
joinedAt: number;
tmuxPaneId: string;
windowId?: string;
cwd: string;
subscriptions: any[];
prompt?: string;
color?: string;
thinking?: "off" | "minimal" | "low" | "medium" | "high";
planModeRequired?: boolean;
backendType?: string;
isActive?: boolean;
}
export interface TeamConfig {
name: string;
description: string;
createdAt: number;
leadAgentId: string;
leadSessionId: string;
members: Member[];
defaultModel?: string;
separateWindows?: boolean;
}
export interface TaskFile {
id: string;
subject: string;
description: string;
activeForm?: string;
status: "pending" | "planning" | "in_progress" | "completed" | "deleted";
plan?: string;
planFeedback?: string;
blocks: string[];
blockedBy: string[];
owner?: string;
metadata?: Record<string, any>;
}
export interface InboxMessage {
from: string;
text: string;
timestamp: string;
read: boolean;
summary?: string;
color?: string;
}

View file

@ -0,0 +1,43 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export const COMPANION_DIR = path.join(os.homedir(), ".companion");
export const TEAMS_DIR = path.join(COMPANION_DIR, "teams");
export const TASKS_DIR = path.join(COMPANION_DIR, "tasks");
export function ensureDirs() {
if (!fs.existsSync(COMPANION_DIR)) fs.mkdirSync(COMPANION_DIR);
if (!fs.existsSync(TEAMS_DIR)) fs.mkdirSync(TEAMS_DIR);
if (!fs.existsSync(TASKS_DIR)) fs.mkdirSync(TASKS_DIR);
}
export function sanitizeName(name: string): string {
// Allow only alphanumeric characters, hyphens, and underscores.
if (/[^a-zA-Z0-9_-]/.test(name)) {
throw new Error(
`Invalid name: "${name}". Only alphanumeric characters, hyphens, and underscores are allowed.`,
);
}
return name;
}
export function teamDir(teamName: string) {
return path.join(TEAMS_DIR, sanitizeName(teamName));
}
export function taskDir(teamName: string) {
return path.join(TASKS_DIR, sanitizeName(teamName));
}
export function inboxPath(teamName: string, agentName: string) {
return path.join(
teamDir(teamName),
"inboxes",
`${sanitizeName(agentName)}.json`,
);
}
export function configPath(teamName: string) {
return path.join(teamDir(teamName), "config.json");
}

View file

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { inboxPath, sanitizeName, teamDir } from "./paths";
describe("Security Audit - Path Traversal (Prevention Check)", () => {
it("should throw an error for path traversal via teamName", () => {
const maliciousTeamName = "../../etc";
expect(() => teamDir(maliciousTeamName)).toThrow();
});
it("should throw an error for path traversal via agentName", () => {
const teamName = "audit-team";
const maliciousAgentName = "../../../.ssh/id_rsa";
expect(() => inboxPath(teamName, maliciousAgentName)).toThrow();
});
it("should throw an error for path traversal via taskId", () => {
const maliciousTaskId = "../../../etc/passwd";
// We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic
// But since we already tested sanitizeName via other paths, this is just for completeness.
expect(() => sanitizeName(maliciousTaskId)).toThrow();
});
});
describe("Security Audit - Command Injection (Fixed)", () => {
it("should not be vulnerable to command injection in spawn_teammate (via parameters)", () => {
const maliciousCwd = "; rm -rf / ;";
const name = "attacker";
const team_name = "audit-team";
const piBinary = "companion";
const cmd = `COMPANION_TEAM_NAME=${team_name} COMPANION_AGENT_NAME=${name} ${piBinary}`;
// Simulating what happens in spawn_teammate (extensions/index.ts)
const itermCmd = `cd '${maliciousCwd}' && ${cmd}`;
// The command becomes: cd '; rm -rf / ;' && COMPANION_TEAM_NAME=audit-team COMPANION_AGENT_NAME=attacker companion
expect(itermCmd).toContain("cd '; rm -rf / ;' &&");
expect(itermCmd).not.toContain("cd ; rm -rf / ; &&");
});
});

View file

@ -0,0 +1,49 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths";
import { createTask } from "./tasks";
const testDir = path.join(os.tmpdir(), `companion-tasks-race-test-${Date.now()}`);
describe("Tasks Race Condition Bug", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockReturnValue(
path.join(testDir, "config.json"),
);
fs.writeFileSync(
path.join(testDir, "config.json"),
JSON.stringify({ name: "test-team" }),
);
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should potentially fail to create unique IDs under high concurrency (Demonstrating Bug 1)", async () => {
const numTasks = 20;
const promises = [];
for (let i = 0; i < numTasks; i++) {
promises.push(createTask("test-team", `Task ${i}`, `Desc ${i}`));
}
const results = await Promise.all(promises);
const ids = results.map((r) => r.id);
const uniqueIds = new Set(ids);
// If Bug 1 exists (getTaskId outside the lock but actually it is inside the lock in createTask),
// this test might still pass because createTask locks the directory.
// WAIT: I noticed createTask uses withLock(lockPath, ...) where lockPath = dir.
// Let's re-verify createTask in src/utils/tasks.ts
expect(uniqueIds.size).toBe(numTasks);
});
});

View file

@ -0,0 +1,215 @@
// Project: companion-teams
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as paths from "./paths";
import {
createTask,
evaluatePlan,
listTasks,
readTask,
submitPlan,
updateTask,
} from "./tasks";
// Mock the paths to use a temporary directory
const testDir = path.join(os.tmpdir(), `companion-teams-test-${Date.now()}`);
describe("Tasks Utilities", () => {
beforeEach(() => {
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
fs.mkdirSync(testDir, { recursive: true });
// Override paths to use testDir
vi.spyOn(paths, "taskDir").mockReturnValue(testDir);
vi.spyOn(paths, "configPath").mockReturnValue(
path.join(testDir, "config.json"),
);
// Create a dummy team config
fs.writeFileSync(
path.join(testDir, "config.json"),
JSON.stringify({ name: "test-team" }),
);
});
afterEach(() => {
vi.restoreAllMocks();
if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true });
});
it("should create a task successfully", async () => {
const task = await createTask(
"test-team",
"Test Subject",
"Test Description",
);
expect(task.id).toBe("1");
expect(task.subject).toBe("Test Subject");
expect(fs.existsSync(path.join(testDir, "1.json"))).toBe(true);
});
it("should update a task successfully", async () => {
await createTask("test-team", "Test Subject", "Test Description");
const updated = await updateTask("test-team", "1", {
status: "in_progress",
});
expect(updated.status).toBe("in_progress");
const taskData = JSON.parse(
fs.readFileSync(path.join(testDir, "1.json"), "utf-8"),
);
expect(taskData.status).toBe("in_progress");
});
it("should submit a plan successfully", async () => {
const task = await createTask(
"test-team",
"Test Subject",
"Test Description",
);
const plan = "Step 1: Do something\nStep 2: Profit";
const updated = await submitPlan("test-team", task.id, plan);
expect(updated.status).toBe("planning");
expect(updated.plan).toBe(plan);
const taskData = JSON.parse(
fs.readFileSync(path.join(testDir, `${task.id}.json`), "utf-8"),
);
expect(taskData.status).toBe("planning");
expect(taskData.plan).toBe(plan);
});
it("should fail to submit an empty plan", async () => {
const task = await createTask("test-team", "Empty Test", "Should fail");
await expect(submitPlan("test-team", task.id, "")).rejects.toThrow(
"Plan must not be empty",
);
await expect(submitPlan("test-team", task.id, " ")).rejects.toThrow(
"Plan must not be empty",
);
});
it("should list tasks", async () => {
await createTask("test-team", "Task 1", "Desc 1");
await createTask("test-team", "Task 2", "Desc 2");
const tasksList = await listTasks("test-team");
expect(tasksList.length).toBe(2);
expect(tasksList[0].id).toBe("1");
expect(tasksList[1].id).toBe("2");
});
it("should have consistent lock paths (Fixed BUG 2)", async () => {
// This test verifies that both updateTask and readTask now use the same lock path
// Both should now lock `${taskId}.json.lock`
await createTask("test-team", "Bug Test", "Testing lock consistency");
const taskId = "1";
const taskFile = path.join(testDir, `${taskId}.json`);
const commonLockFile = `${taskFile}.lock`;
// 1. Holding the common lock
fs.writeFileSync(commonLockFile, "9999");
// 2. Try updateTask, it should fail
// Using small retries to speed up the test and avoid fake timer issues with native setTimeout
await expect(
updateTask("test-team", taskId, { status: "in_progress" }, 2),
).rejects.toThrow("Could not acquire lock");
// 3. Try readTask, it should fail too
await expect(readTask("test-team", taskId, 2)).rejects.toThrow(
"Could not acquire lock",
);
fs.unlinkSync(commonLockFile);
});
it("should approve a plan successfully", async () => {
const task = await createTask(
"test-team",
"Plan Test",
"Should be approved",
);
await submitPlan("test-team", task.id, "Wait for it...");
const approved = await evaluatePlan("test-team", task.id, "approve");
expect(approved.status).toBe("in_progress");
expect(approved.planFeedback).toBe("");
});
it("should reject a plan with feedback", async () => {
const task = await createTask(
"test-team",
"Plan Test",
"Should be rejected",
);
await submitPlan("test-team", task.id, "Wait for it...");
const feedback = "Not good enough!";
const rejected = await evaluatePlan(
"test-team",
task.id,
"reject",
feedback,
);
expect(rejected.status).toBe("planning");
expect(rejected.planFeedback).toBe(feedback);
});
it("should fail to evaluate a task not in 'planning' status", async () => {
const task = await createTask(
"test-team",
"Status Test",
"Invalid status for eval",
);
// status is "pending"
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow(
"must be in 'planning' status",
);
});
it("should fail to evaluate a task without a plan", async () => {
const task = await createTask(
"test-team",
"Plan Missing Test",
"No plan submitted",
);
await updateTask("test-team", task.id, { status: "planning" }); // bypass submitPlan to have no plan
await expect(evaluatePlan("test-team", task.id, "approve")).rejects.toThrow(
"no plan has been submitted",
);
});
it("should fail to reject a plan without feedback", async () => {
const task = await createTask(
"test-team",
"Feedback Test",
"Should require feedback",
);
await submitPlan("test-team", task.id, "My plan");
await expect(evaluatePlan("test-team", task.id, "reject")).rejects.toThrow(
"Feedback is required when rejecting a plan",
);
await expect(
evaluatePlan("test-team", task.id, "reject", " "),
).rejects.toThrow("Feedback is required when rejecting a plan");
});
it("should sanitize task IDs in all file operations", async () => {
const dirtyId = "../evil-id";
// sanitizeName should throw on this dirtyId
await expect(readTask("test-team", dirtyId)).rejects.toThrow(
/Invalid name: "..\/evil-id"/,
);
await expect(
updateTask("test-team", dirtyId, { status: "in_progress" }),
).rejects.toThrow(/Invalid name: "..\/evil-id"/);
await expect(evaluatePlan("test-team", dirtyId, "approve")).rejects.toThrow(
/Invalid name: "..\/evil-id"/,
);
});
});

View file

@ -0,0 +1,214 @@
// Project: companion-teams
import fs from "node:fs";
import path from "node:path";
import { runHook } from "./hooks";
import { withLock } from "./lock";
import type { TaskFile } from "./models";
import { sanitizeName, taskDir } from "./paths";
import { teamExists } from "./teams";
export function getTaskId(teamName: string): string {
const dir = taskDir(teamName);
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const ids = files
.map((f) => parseInt(path.parse(f).name, 10))
.filter((id) => !Number.isNaN(id));
return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1";
}
function getTaskPath(teamName: string, taskId: string): string {
const dir = taskDir(teamName);
const safeTaskId = sanitizeName(taskId);
return path.join(dir, `${safeTaskId}.json`);
}
export async function createTask(
teamName: string,
subject: string,
description: string,
activeForm = "",
metadata?: Record<string, any>,
): Promise<TaskFile> {
if (!subject || !subject.trim())
throw new Error("Task subject must not be empty");
if (!teamExists(teamName)) throw new Error(`Team ${teamName} does not exist`);
const dir = taskDir(teamName);
const lockPath = dir;
return await withLock(lockPath, async () => {
const id = getTaskId(teamName);
const task: TaskFile = {
id,
subject,
description,
activeForm,
status: "pending",
blocks: [],
blockedBy: [],
metadata,
};
fs.writeFileSync(
path.join(dir, `${id}.json`),
JSON.stringify(task, null, 2),
);
return task;
});
}
export async function updateTask(
teamName: string,
taskId: string,
updates: Partial<TaskFile>,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
const updated = { ...task, ...updates };
if (updates.status === "deleted") {
fs.unlinkSync(p);
return updated;
}
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
if (updates.status === "completed") {
await runHook(teamName, "task_completed", updated);
}
return updated;
},
retries,
);
}
/**
* Submits a plan for a task, updating its status to "planning".
* @param teamName The name of the team
* @param taskId The ID of the task
* @param plan The content of the plan
* @returns The updated task
*/
export async function submitPlan(
teamName: string,
taskId: string,
plan: string,
): Promise<TaskFile> {
if (!plan || !plan.trim()) throw new Error("Plan must not be empty");
return await updateTask(teamName, taskId, { status: "planning", plan });
}
/**
* Evaluates a submitted plan for a task.
* @param teamName The name of the team
* @param taskId The ID of the task
* @param action The evaluation action: "approve" or "reject"
* @param feedback Optional feedback for the evaluation (required for rejection)
* @param retries Number of times to retry acquiring the lock
* @returns The updated task
*/
export async function evaluatePlan(
teamName: string,
taskId: string,
action: "approve" | "reject",
feedback?: string,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
return await withLock(
p,
async () => {
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
// 1. Validate state: Only "planning" tasks can be evaluated
if (task.status !== "planning") {
throw new Error(
`Cannot evaluate plan for task ${taskId} because its status is '${task.status}'. ` +
`Tasks must be in 'planning' status to be evaluated.`,
);
}
// 2. Validate plan presence
if (!task.plan || !task.plan.trim()) {
throw new Error(
`Cannot evaluate plan for task ${taskId} because no plan has been submitted.`,
);
}
// 3. Require feedback for rejections
if (action === "reject" && (!feedback || !feedback.trim())) {
throw new Error("Feedback is required when rejecting a plan.");
}
// 4. Perform update
const updates: Partial<TaskFile> =
action === "approve"
? { status: "in_progress", planFeedback: "" }
: { status: "planning", planFeedback: feedback };
const updated = { ...task, ...updates };
fs.writeFileSync(p, JSON.stringify(updated, null, 2));
return updated;
},
retries,
);
}
export async function readTask(
teamName: string,
taskId: string,
retries?: number,
): Promise<TaskFile> {
const p = getTaskPath(teamName, taskId);
if (!fs.existsSync(p)) throw new Error(`Task ${taskId} not found`);
return await withLock(
p,
async () => {
return JSON.parse(fs.readFileSync(p, "utf-8"));
},
retries,
);
}
export async function listTasks(teamName: string): Promise<TaskFile[]> {
const dir = taskDir(teamName);
return await withLock(dir, async () => {
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
const tasks: TaskFile[] = files
.map((f) => {
const id = parseInt(path.parse(f).name, 10);
if (Number.isNaN(id)) return null;
return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
})
.filter((t) => t !== null);
return tasks.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
});
}
export async function resetOwnerTasks(teamName: string, agentName: string) {
const dir = taskDir(teamName);
const lockPath = dir;
await withLock(lockPath, async () => {
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
for (const f of files) {
const p = path.join(dir, f);
const task: TaskFile = JSON.parse(fs.readFileSync(p, "utf-8"));
if (task.owner === agentName) {
task.owner = undefined;
if (task.status !== "completed") {
task.status = "pending";
}
fs.writeFileSync(p, JSON.stringify(task, null, 2));
}
}
});
}

View file

@ -0,0 +1,93 @@
import fs from "node:fs";
import { withLock } from "./lock";
import type { Member, TeamConfig } from "./models";
import { configPath, taskDir, teamDir } from "./paths";
export function teamExists(teamName: string) {
return fs.existsSync(configPath(teamName));
}
export function createTeam(
name: string,
sessionId: string,
leadAgentId: string,
description = "",
defaultModel?: string,
separateWindows?: boolean,
): TeamConfig {
const dir = teamDir(name);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tasksDir = taskDir(name);
if (!fs.existsSync(tasksDir)) fs.mkdirSync(tasksDir, { recursive: true });
const leadMember: Member = {
agentId: leadAgentId,
name: "team-lead",
agentType: "lead",
joinedAt: Date.now(),
tmuxPaneId: process.env.TMUX_PANE || "",
cwd: process.cwd(),
subscriptions: [],
};
const config: TeamConfig = {
name,
description,
createdAt: Date.now(),
leadAgentId,
leadSessionId: sessionId,
members: [leadMember],
defaultModel,
separateWindows,
};
fs.writeFileSync(configPath(name), JSON.stringify(config, null, 2));
return config;
}
function readConfigRaw(p: string): TeamConfig {
return JSON.parse(fs.readFileSync(p, "utf-8"));
}
export async function readConfig(teamName: string): Promise<TeamConfig> {
const p = configPath(teamName);
if (!fs.existsSync(p)) throw new Error(`Team ${teamName} not found`);
return await withLock(p, async () => {
return readConfigRaw(p);
});
}
export async function addMember(teamName: string, member: Member) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
config.members.push(member);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
});
}
export async function removeMember(teamName: string, agentName: string) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
config.members = config.members.filter((m) => m.name !== agentName);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
});
}
export async function updateMember(
teamName: string,
agentName: string,
updates: Partial<Member>,
) {
const p = configPath(teamName);
await withLock(p, async () => {
const config = readConfigRaw(p);
const m = config.members.find((m) => m.name === agentName);
if (m) {
Object.assign(m, updates);
fs.writeFileSync(p, JSON.stringify(config, null, 2));
}
});
}

View file

@ -0,0 +1,133 @@
/**
* Terminal Adapter Interface
*
* Abstracts terminal multiplexer operations (tmux, iTerm2, Zellij)
* to provide a unified API for spawning, managing, and terminating panes.
*/
import { spawnSync } from "node:child_process";
/**
* Options for spawning a new terminal pane or window
*/
export interface SpawnOptions {
/** Name/identifier for the pane/window */
name: string;
/** Working directory for the new pane/window */
cwd: string;
/** Command to execute in the pane/window */
command: string;
/** Environment variables to set (key-value pairs) */
env: Record<string, string>;
/** Team name for window title formatting (e.g., "team: agent") */
teamName?: string;
}
/**
* Terminal Adapter Interface
*
* Implementations provide terminal-specific logic for pane management.
*/
export interface TerminalAdapter {
/** Unique name identifier for this terminal type */
readonly name: string;
/**
* Detect if this terminal is currently available/active.
* Should check for terminal-specific environment variables or processes.
*
* @returns true if this terminal should be used
*/
detect(): boolean;
/**
* Spawn a new terminal pane with the given options.
*
* @param options - Spawn configuration
* @returns Pane ID that can be used for subsequent operations
* @throws Error if spawn fails
*/
spawn(options: SpawnOptions): string;
/**
* Kill/terminate a terminal pane.
* Should be idempotent - no error if pane doesn't exist.
*
* @param paneId - The pane ID returned from spawn()
*/
kill(paneId: string): void;
/**
* Check if a terminal pane is still alive/active.
*
* @param paneId - The pane ID returned from spawn()
* @returns true if pane exists and is active
*/
isAlive(paneId: string): boolean;
/**
* Set the title of the current terminal pane/window.
* Used for identifying panes in the terminal UI.
*
* @param title - The title to set
*/
setTitle(title: string): void;
/**
* Check if this terminal supports spawning separate OS windows.
* Terminals like tmux and Zellij only support panes/tabs within a session.
*
* @returns true if spawnWindow() is supported
*/
supportsWindows(): boolean;
/**
* Spawn a new separate OS window with the given options.
* Only available if supportsWindows() returns true.
*
* @param options - Spawn configuration
* @returns Window ID that can be used for subsequent operations
* @throws Error if spawn fails or not supported
*/
spawnWindow(options: SpawnOptions): string;
/**
* Set the title of a specific window.
* Used for identifying windows in the OS window manager.
*
* @param windowId - The window ID returned from spawnWindow()
* @param title - The title to set
*/
setWindowTitle(windowId: string, title: string): void;
/**
* Kill/terminate a window.
* Should be idempotent - no error if window doesn't exist.
*
* @param windowId - The window ID returned from spawnWindow()
*/
killWindow(windowId: string): void;
/**
* Check if a window is still alive/active.
*
* @param windowId - The window ID returned from spawnWindow()
* @returns true if window exists and is active
*/
isWindowAlive(windowId: string): boolean;
}
/**
* Base helper for adapters to execute commands synchronously.
*/
export function execCommand(
command: string,
args: string[],
): { stdout: string; stderr: string; status: number | null } {
const result = spawnSync(command, args, { encoding: "utf-8" });
return {
stdout: result.stdout?.toString() ?? "",
stderr: result.stderr?.toString() ?? "",
status: result.status,
};
}

View file

@ -0,0 +1,174 @@
# Implementation Plan: Separate Windows Mode for companion-teams
## Goal
Implement the ability to open team members (including the team lead) in separate OS windows instead of panes, with window titles set to "team-name: agent-name" format.
## Research Summary
### Terminal Support Matrix
| Terminal | New Window Support | Window Title Method | Notes |
| ----------- | --------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **iTerm2** | ✅ AppleScript `create window with default profile` | AppleScript `set name` on session (tab) + escape sequences for window title | Primary target; window title property is read-only, use escape sequence `\033]2;Title\007` |
| **WezTerm** | ✅ `wezterm cli spawn --new-window` | `wezterm cli set-window-title` or escape sequences | Full support |
| **tmux** | ❌ Skipped | N/A | Only creates windows within session, not OS windows |
| **Zellij** | ❌ Skipped | N/A | Only creates tabs, not OS windows |
### Key Technical Findings
1. **iTerm2 AppleScript for New Window:**
```applescript
tell application "iTerm"
set newWindow to (create window with default profile)
tell current session of newWindow
write text "printf '\\033]2;Team: Agent\\007'" -- Set window title via escape sequence
set name to "tab-title" -- Optional: set tab title
end tell
end tell
```
2. **WezTerm CLI for New Window:**
```bash
wezterm cli spawn --new-window --cwd /path -- env KEY=val command
wezterm cli set-window-title --window-id X "Team: Agent"
```
3. **Escape Sequence for Window Title (Universal):**
```bash
printf '\033]2;Window Title\007'
```
## Implementation Phases
### Phase 1: Update Terminal Adapter Interface
**Status:** pending
**Files:** `src/utils/terminal-adapter.ts`
- [ ] Add `spawnWindow(options: SpawnOptions): string` method to `TerminalAdapter` interface
- [ ] Add `setWindowTitle(windowId: string, title: string): void` method to `TerminalAdapter` interface
- [ ] Update `SpawnOptions` to include optional `teamName?: string` for title formatting
### Phase 2: Implement iTerm2 Window Support
**Status:** pending
**Files:** `src/adapters/iterm2-adapter.ts`
- [ ] Implement `spawnWindow()` using AppleScript `create window with default profile`
- [ ] Capture and return window ID from AppleScript
- [ ] Implement `setWindowTitle()` using escape sequence injection via `write text`
- [ ] Format title as `{teamName}: {agentName}`
- [ ] Handle window lifecycle (track window IDs)
### Phase 3: Implement WezTerm Window Support
**Status:** pending
**Files:** `src/adapters/wezterm-adapter.ts`
- [ ] Implement `spawnWindow()` using `wezterm cli spawn --new-window`
- [ ] Capture and return window ID from spawn output
- [ ] Implement `setWindowTitle()` using `wezterm cli set-window-title`
- [ ] Format title as `{teamName}: {agentName}`
### Phase 4: Update Terminal Registry
**Status:** pending
**Files:** `src/adapters/terminal-registry.ts`
- [ ] Add feature detection method `supportsWindows(): boolean`
- [ ] Update registry to expose window capabilities
### Phase 5: Update Team Configuration
**Status:** pending
**Files:** `src/utils/models.ts`, `src/utils/teams.ts`
- [ ] Add `separateWindows?: boolean` to `TeamConfig` model
- [ ] Add `windowId?: string` to `Member` model (for tracking OS window IDs)
- [ ] Update `createTeam()` to accept and store `separateWindows` option
### Phase 6: Update spawn_teammate Tool
**Status:** pending
**Files:** `extensions/index.ts`
- [ ] Add `separate_window?: boolean` parameter to `spawn_teammate` tool
- [ ] Check team config for global `separateWindows` setting
- [ ] Use `spawnWindow()` instead of `spawn()` when separate windows mode is active
- [ ] Store window ID in member record instead of pane ID
- [ ] Set window title immediately after spawn using `setWindowTitle()`
### Phase 7: Create spawn_lead_window Tool (Optional)
**Status:** pending
**Files:** `extensions/index.ts`
- [ ] Create new tool `spawn_lead_window` to move team lead to separate window
- [ ] Only available if team has `separateWindows: true`
- [ ] Set window title for lead as `{teamName}: team-lead`
### Phase 8: Update Kill/Lifecycle Management
**Status:** pending
**Files:** `extensions/index.ts`, adapter files
- [ ] Update `killTeammate()` to handle window IDs (not just pane IDs)
- [ ] Implement window closing via AppleScript (iTerm2) or CLI (WezTerm)
- [ ] Update `isAlive()` checks for window-based teammates
### Phase 9: Testing & Validation
**Status:** pending
- [ ] Test iTerm2 window creation and title setting
- [ ] Test WezTerm window creation and title setting
- [ ] Test global `separateWindows` team setting
- [ ] Test per-teammate `separate_window` override
- [ ] Test window lifecycle (kill, isAlive)
- [ ] Verify title format: `{teamName}: {agentName}`
### Phase 10: Documentation
**Status:** pending
**Files:** `README.md`, `docs/guide.md`, `docs/reference.md`
- [ ] Document new `separate_window` parameter
- [ ] Document global `separateWindows` team setting
- [ ] Add iTerm2 and WezTerm window mode examples
- [ ] Update terminal requirements section
## Design Decisions
1. **Window Title Strategy:** Use escape sequences (`\033]2;Title\007`) for iTerm2 window titles since AppleScript's window title property is read-only. Tab titles will use the session `name` property.
2. **ID Tracking:** Store window IDs in the same `tmuxPaneId` field (renamed conceptually to `terminalId`) or add a new `windowId` field to Member model. Decision: Add `windowId` field to be explicit.
3. **Fallback Behavior:** If `separate_window: true` is requested but terminal doesn't support it, throw an error with clear message.
4. **Lead Window:** Team lead window is optional and must be explicitly requested via a separate tool call after team creation.
## Open Questions
None - all clarified by user.
## Errors Encountered
| Error | Attempt | Resolution |
| ----- | ------- | ---------- |
| N/A | - | - |
## Files to Modify
1. `src/utils/terminal-adapter.ts` - Add interface methods
2. `src/adapters/iterm2-adapter.ts` - Implement window support
3. `src/adapters/wezterm-adapter.ts` - Implement window support
4. `src/adapters/terminal-registry.ts` - Add capability detection
5. `src/utils/models.ts` - Update Member and TeamConfig types
6. `src/utils/teams.ts` - Update createTeam signature
7. `extensions/index.ts` - Update spawn_teammate, add spawn_lead_window
8. `README.md` - Document new feature
9. `docs/guide.md` - Add usage examples
10. `docs/reference.md` - Update tool documentation

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*", "extensions/**/*"],
"exclude": ["node_modules", "dist"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB