mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-17 10:02:23 +00:00
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:
parent
e8fe3d54af
commit
536241053c
303 changed files with 3603 additions and 3602 deletions
5
packages/companion-teams/.gitignore
vendored
Normal file
5
packages/companion-teams/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
.companion
|
||||
dist
|
||||
*.log
|
||||
107
packages/companion-teams/AGENTS.md
Normal file
107
packages/companion-teams/AGENTS.md
Normal 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.
|
||||
0
packages/companion-teams/APPLESCRIPT
Normal file
0
packages/companion-teams/APPLESCRIPT
Normal file
0
packages/companion-teams/EOF
Normal file
0
packages/companion-teams/EOF
Normal file
0
packages/companion-teams/PATCH
Normal file
0
packages/companion-teams/PATCH
Normal file
188
packages/companion-teams/README.md
Normal file
188
packages/companion-teams/README.md
Normal 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
|
||||
66
packages/companion-teams/WEZTERM_LAYOUT_FIX.md
Normal file
66
packages/companion-teams/WEZTERM_LAYOUT_FIX.md
Normal 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
|
||||
```
|
||||
115
packages/companion-teams/WEZTERM_SUPPORT.md
Normal file
115
packages/companion-teams/WEZTERM_SUPPORT.md
Normal 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!
|
||||
0
packages/companion-teams/context.md
Normal file
0
packages/companion-teams/context.md
Normal file
396
packages/companion-teams/docs/guide.md
Normal file
396
packages/companion-teams/docs/guide.md
Normal 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'"
|
||||
|
|
@ -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**
|
||||
703
packages/companion-teams/docs/reference.md
Normal file
703
packages/companion-teams/docs/reference.md
Normal 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/
|
||||
```
|
||||
467
packages/companion-teams/docs/terminal-app-research.md
Normal file
467
packages/companion-teams/docs/terminal-app-research.md
Normal 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**
|
||||
58
packages/companion-teams/docs/test-0.6.0.md
Normal file
58
packages/companion-teams/docs/test-0.6.0.md
Normal 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."
|
||||
94
packages/companion-teams/docs/test-0.7.0.md
Normal file
94
packages/companion-teams/docs/test-0.7.0.md
Normal 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."
|
||||
920
packages/companion-teams/docs/vscode-terminal-research.md
Normal file
920
packages/companion-teams/docs/vscode-terminal-research.md
Normal 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
|
||||
818
packages/companion-teams/extensions/index.ts
Normal file
818
packages/companion-teams/extensions/index.ts
Normal 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: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
114
packages/companion-teams/findings.md
Normal file
114
packages/companion-teams/findings.md
Normal 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
|
||||
BIN
packages/companion-teams/iTerm2.png
Normal file
BIN
packages/companion-teams/iTerm2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
5507
packages/companion-teams/package-lock.json
generated
Normal file
5507
packages/companion-teams/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
packages/companion-teams/package.json
Normal file
47
packages/companion-teams/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
packages/companion-teams/pi-team-in-action.png
Normal file
BIN
packages/companion-teams/pi-team-in-action.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
40
packages/companion-teams/progress.md
Normal file
40
packages/companion-teams/progress.md
Normal 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)
|
||||
2
packages/companion-teams/publish-to-npm.sh
Executable file
2
packages/companion-teams/publish-to-npm.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
npm publish --access public
|
||||
|
||||
50
packages/companion-teams/skills/teams.md
Normal file
50
packages/companion-teams/skills/teams.md
Normal 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.
|
||||
222
packages/companion-teams/src/adapters/cmux-adapter.ts
Normal file
222
packages/companion-teams/src/adapters/cmux-adapter.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
320
packages/companion-teams/src/adapters/iterm2-adapter.ts
Normal file
320
packages/companion-teams/src/adapters/iterm2-adapter.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
123
packages/companion-teams/src/adapters/terminal-registry.ts
Normal file
123
packages/companion-teams/src/adapters/terminal-registry.ts
Normal 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;
|
||||
}
|
||||
113
packages/companion-teams/src/adapters/tmux-adapter.test.ts
Normal file
113
packages/companion-teams/src/adapters/tmux-adapter.test.ts
Normal 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}",
|
||||
]);
|
||||
});
|
||||
});
|
||||
190
packages/companion-teams/src/adapters/tmux-adapter.ts
Normal file
190
packages/companion-teams/src/adapters/tmux-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
122
packages/companion-teams/src/adapters/wezterm-adapter.test.ts
Normal file
122
packages/companion-teams/src/adapters/wezterm-adapter.test.ts
Normal 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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
packages/companion-teams/src/adapters/wezterm-adapter.ts
Normal file
366
packages/companion-teams/src/adapters/wezterm-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
109
packages/companion-teams/src/adapters/zellij-adapter.ts
Normal file
109
packages/companion-teams/src/adapters/zellij-adapter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
79
packages/companion-teams/src/utils/hooks.test.ts
Normal file
79
packages/companion-teams/src/utils/hooks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
packages/companion-teams/src/utils/hooks.ts
Normal file
44
packages/companion-teams/src/utils/hooks.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
46
packages/companion-teams/src/utils/lock.race.test.ts
Normal file
46
packages/companion-teams/src/utils/lock.race.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
packages/companion-teams/src/utils/lock.test.ts
Normal file
51
packages/companion-teams/src/utils/lock.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
50
packages/companion-teams/src/utils/lock.ts
Normal file
50
packages/companion-teams/src/utils/lock.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
130
packages/companion-teams/src/utils/messaging.test.ts
Normal file
130
packages/companion-teams/src/utils/messaging.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
packages/companion-teams/src/utils/messaging.ts
Normal file
120
packages/companion-teams/src/utils/messaging.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
packages/companion-teams/src/utils/models.ts
Normal file
51
packages/companion-teams/src/utils/models.ts
Normal 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;
|
||||
}
|
||||
43
packages/companion-teams/src/utils/paths.ts
Normal file
43
packages/companion-teams/src/utils/paths.ts
Normal 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");
|
||||
}
|
||||
39
packages/companion-teams/src/utils/security.test.ts
Normal file
39
packages/companion-teams/src/utils/security.test.ts
Normal 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 / ; &&");
|
||||
});
|
||||
});
|
||||
49
packages/companion-teams/src/utils/tasks.race.test.ts
Normal file
49
packages/companion-teams/src/utils/tasks.race.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
215
packages/companion-teams/src/utils/tasks.test.ts
Normal file
215
packages/companion-teams/src/utils/tasks.test.ts
Normal 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"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
214
packages/companion-teams/src/utils/tasks.ts
Normal file
214
packages/companion-teams/src/utils/tasks.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
93
packages/companion-teams/src/utils/teams.ts
Normal file
93
packages/companion-teams/src/utils/teams.ts
Normal 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
133
packages/companion-teams/src/utils/terminal-adapter.ts
Normal file
133
packages/companion-teams/src/utils/terminal-adapter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
174
packages/companion-teams/task_plan.md
Normal file
174
packages/companion-teams/task_plan.md
Normal 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
|
||||
BIN
packages/companion-teams/tmux.png
Normal file
BIN
packages/companion-teams/tmux.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
14
packages/companion-teams/tsconfig.json
Normal file
14
packages/companion-teams/tsconfig.json
Normal 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"]
|
||||
}
|
||||
BIN
packages/companion-teams/zellij.png
Normal file
BIN
packages/companion-teams/zellij.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
Loading…
Add table
Add a link
Reference in a new issue