diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index f1e3df9..1aae1b6 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -415,6 +415,40 @@ jobs:
--generate-notes \
release-assets/*
+ - name: Generate checksums.yml
+ run: |
+ OP_MACOS=$(sha256sum release-assets/operator-macos-arm64 | cut -d' ' -f1)
+ OP_LINUX_ARM=$(sha256sum release-assets/operator-linux-arm64 | cut -d' ' -f1)
+ OP_LINUX_X86=$(sha256sum release-assets/operator-linux-x86_64 | cut -d' ' -f1)
+ OP_WIN=$(sha256sum release-assets/operator-windows-x86_64.exe | cut -d' ' -f1)
+ BS_DARWIN=$(sha256sum release-assets/backstage-server-bun-darwin-arm64 | cut -d' ' -f1)
+ BS_LINUX_ARM=$(sha256sum release-assets/backstage-server-bun-linux-arm64 | cut -d' ' -f1)
+ BS_LINUX_X64=$(sha256sum release-assets/backstage-server-bun-linux-x64 | cut -d' ' -f1)
+ BS_WIN=$(sha256sum release-assets/backstage-server-bun-windows-x64 | cut -d' ' -f1)
+
+ cat > docs/_data/checksums.yml << EOF
+ # SHA256 checksums - AUTO-GENERATED BY CI
+ # Do not edit manually - regenerated on each release
+
+ operator:
+ macos_arm64: "${OP_MACOS}"
+ linux_arm64: "${OP_LINUX_ARM}"
+ linux_x86_64: "${OP_LINUX_X86}"
+ windows_x86_64: "${OP_WIN}"
+
+ backstage:
+ darwin_arm64: "${BS_DARWIN}"
+ linux_arm64: "${BS_LINUX_ARM}"
+ linux_x64: "${BS_LINUX_X64}"
+ windows_x64: "${BS_WIN}"
+ EOF
+
+ - name: Commit checksums
+ run: |
+ git add docs/_data/checksums.yml
+ git commit -m "chore: update checksums for v${{ steps.version.outputs.version }} [skip ci]" || true
+ git push
+
deploy-docs:
needs: release
runs-on: ubuntu-latest
diff --git a/.github/workflows/zed-extension.yaml b/.github/workflows/zed-extension.yaml
new file mode 100644
index 0000000..f815b14
--- /dev/null
+++ b/.github/workflows/zed-extension.yaml
@@ -0,0 +1,53 @@
+name: Zed Extension
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'zed-extension/**'
+ - '.github/workflows/zed-extension.yaml'
+ pull_request:
+ paths:
+ - 'zed-extension/**'
+ - '.github/workflows/zed-extension.yaml'
+
+jobs:
+ lint-build:
+ name: Lint and Build
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: zed-extension
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-wasip1
+ components: clippy, rustfmt
+
+ - name: Cache cargo
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: zed-extension -> target
+
+ - name: Check formatting
+ run: cargo fmt -- --check
+
+ - name: Lint
+ run: cargo clippy --target wasm32-wasip1 -- -D warnings
+
+ - name: Build
+ run: cargo build --release --target wasm32-wasip1
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: operator-zed-extension
+ path: |
+ zed-extension/extension.toml
+ zed-extension/target/wasm32-wasip1/release/operator_zed.wasm
+ if-no-files-found: error
diff --git a/.gitignore b/.gitignore
index dbf65b6..2897269 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Rust build artifacts
/target/
opr8r/target/
+zed-extension/target/
# Rust backup files
*.rs.bk
diff --git a/README.md b/README.md
index d78e5df..ab21603 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,15 @@
A multi-agent orchestration dashboard for [**AI-assisted**](https://operator.untra.io/getting-started/agents/) [_kanban-shaped_](https://operator.untra.io/getting-started/kanban/) [git-versioned](https://operator.untra.io/getting-started/git/) software development.
+Install Operator! Terminals extension from Visual Studio Code Marketplace
+
Operator is for you if:
- you do work assigned from tickets on a kanban board , such as [_Jira Cloud_](https://operator.untra.io/getting-started/kanban/jira/) or [_Linear_](https://operator.untra.io/getting-started/kanban/linear/)
- you use llm assisted coding agent tools to accomplish work, such as [_Claude Code_](https://operator.untra.io/getting-started/agents/claude/), [_OpenAI Codex_](https://operator.untra.io/getting-started/agents/codex/)
- your work is version controlled with git repository provider (like [_Github_](https://operator.untra.io/getting-started/git/github/))
-- you are drowning in the soup
+- you are drowning in the AI software development soup.
and you're ready to start seriously automating your work.
diff --git a/docs/_data/checksums.yml b/docs/_data/checksums.yml
new file mode 100644
index 0000000..5f78288
--- /dev/null
+++ b/docs/_data/checksums.yml
@@ -0,0 +1,14 @@
+# SHA256 checksums - AUTO-GENERATED BY CI
+# Do not edit manually - regenerated on each release
+
+operator:
+ macos_arm64: "da6ed97227e3388810386657a654b12c221754567ddf52119393a64d2cdd6de1"
+ linux_arm64: "285908fd637e6461bfd03ea73c2c8c8e5cc6e80d1e30ecbfbcc0827a85c674a4"
+ linux_x86_64: "4aac345f7380615d72a5ccf1fa223194660e20f28e8764ed7540a1c44e63dcc9"
+ windows_x86_64: "184cf45c0bfa7fefeef8e553a08c90e5b82234d4262fd4b055de58531f0b8493"
+
+backstage:
+ darwin_arm64: "3f5dd85cf2db4a9e431d1869e4a12c782f0ab414100791f3d8c760d2d123c419"
+ linux_arm64: "ae653fdca149be960294843e3e127c2d2a41d18be942904b2bc8550cc08b47fd"
+ linux_x64: "bd51046f067c818b7c84a622460dec93b34252c54bb66162234c52a755140eea"
+ windows_x64: "0019dfc4b32d63c1392aa264aed2253c1e0c2fb09216f8e2cc269bbfb8bb49b5"
diff --git a/docs/downloads/index.md b/docs/downloads/index.md
index 571ce19..4469a29 100644
--- a/docs/downloads/index.md
+++ b/docs/downloads/index.md
@@ -8,20 +8,32 @@ layout: doc
Download Operator! for your platform. Current version: **v{{ site.version }}**
-> **Note:** Windows builds run in **limited mode** - queue management, REST API, and backstage-server work normally, but agent launching requires tmux (available via WSL).
+## VS Code Extension (Recommended)
+
+The **VS Code Extension** is the recommended way to get started with Operator. It provides integrated terminal management, ticket tracking, and a streamlined workflow directly in your editor.
+
+Install from VS Code Marketplace
+
+Works on **macOS**, **Linux**, and **Windows** - no additional setup required.
+
+---
+
+## CLI Downloads
+
+For headless servers, CI/CD pipelines, or advanced workflows, download the CLI binary for your platform.
-## All Operator! Downloads
+### All Operator! Downloads
| Platform | Architecture | Download |
|----------|--------------|----------|
-| macOS | ARM64 (Apple Silicon) | [operator-macos-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-macos-arm64) |
-| Linux | ARM64 | [operator-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-arm64) |
-| Linux | x86_64 | [operator-linux-x86_64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-x86_64) |
-| Windows | x86_64 | [operator-windows-x86_64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-x86_64.exe) |
+| macOS | ARM64 (Apple Silicon) | [operator-macos-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-macos-arm64)
sha256:{{ site.data.checksums.operator.macos_arm64 }} |
+| Linux | ARM64 | [operator-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-arm64)
sha256:{{ site.data.checksums.operator.linux_arm64 }} |
+| Linux | x86_64 | [operator-linux-x86_64]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-linux-x86_64)
sha256:{{ site.data.checksums.operator.linux_x86_64 }} |
+| Windows | x86_64 | [operator-windows-x86_64.exe]({{ site.github.repo }}/releases/download/v{{ site.version }}/operator-windows-x86_64.exe)
sha256:{{ site.data.checksums.operator.windows_x86_64 }} |
## Backstage Server
@@ -29,10 +41,10 @@ Optional companion server for web-based project monitoring dashboard.
| Platform | Architecture | Download |
|----------|--------------|----------|
-| macOS | ARM64 | [backstage-server-bun-darwin-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-darwin-arm64) |
-| Linux | ARM64 | [backstage-server-bun-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-arm64) |
-| Linux | x64 | [backstage-server-bun-linux-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-x64) |
-| Windows | x64 | [backstage-server-bun-windows-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-windows-x64) |
+| macOS | ARM64 | [backstage-server-bun-darwin-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-darwin-arm64)
sha256:{{ site.data.checksums.backstage.darwin_arm64 }} |
+| Linux | ARM64 | [backstage-server-bun-linux-arm64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-arm64)
sha256:{{ site.data.checksums.backstage.linux_arm64 }} |
+| Linux | x64 | [backstage-server-bun-linux-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-linux-x64)
sha256:{{ site.data.checksums.backstage.linux_x64 }} |
+| Windows | x64 | [backstage-server-bun-windows-x64]({{ site.github.repo }}/releases/download/v{{ site.version }}/backstage-server-bun-windows-x64)
sha256:{{ site.data.checksums.backstage.windows_x64 }} |
## All Releases
@@ -59,14 +71,13 @@ Optional companion server for web-based project monitoring dashboard.
// Render the download recommendation
function render(os, arch) {
- var artifactName, url, label, archLabel, limitedModeNote = '';
+ var artifactName, url, label, archLabel;
if (os === 'windows') {
artifactName = 'operator-windows-' + arch + '.exe';
url = '{{ site.github.repo }}/releases/download/v{{ site.version }}/' + artifactName;
label = 'Windows';
archLabel = arch === 'arm64' ? 'ARM64' : 'x86_64';
- limitedModeNote = 'Windows builds run in limited mode (no tmux agent launching)
';
} else {
artifactName = 'operator-' + os + '-' + arch;
url = '{{ site.github.repo }}/releases/download/v{{ site.version }}/' + artifactName;
@@ -76,7 +87,6 @@ Optional companion server for web-based project monitoring dashboard.
container.innerHTML = '';
}
diff --git a/docs/getting-started/sessions/index.md b/docs/getting-started/sessions/index.md
index d0d41a4..84ae98f 100644
--- a/docs/getting-started/sessions/index.md
+++ b/docs/getting-started/sessions/index.md
@@ -12,8 +12,8 @@ Operator supports multiple session management backends for running AI coding age
| Option | Status | Notes |
|--------|--------|-------|
-| [tmux](/getting-started/sessions/tmux/) | Recommended | Terminal multiplexer, works headless |
-| [VS Code Extension](/getting-started/sessions/vscode/) | Supported | Integrated terminals in VS Code |
+| [VS Code Extension](/getting-started/sessions/vscode/) | Recommended (Preferred) | Integrated terminals in VS Code, works on all platforms |
+| [tmux](/getting-started/sessions/tmux/) | Supported | Terminal multiplexer, ideal for headless/server environments |
## How It Works
@@ -26,9 +26,9 @@ Session managers provide:
## Choosing a Session Manager
-**tmux** is recommended for most users, especially those running agents on remote servers or headless environments. It works anywhere with a terminal.
+**VS Code Extension** is the recommended choice for most users. It provides an integrated experience with ticket management, color-coded terminals, and works seamlessly on macOS, Linux, and Windows without additional setup.
-**VS Code Extension** is ideal for developers who prefer working within VS Code and want integrated ticket management alongside their editor.
+**tmux** remains an excellent choice for headless/server environments, SSH sessions, and users who prefer terminal-based workflows. It's particularly useful for remote servers where VS Code may not be available.
## Feature Parity: Core Operations
diff --git a/docs/getting-started/sessions/tmux/index.md b/docs/getting-started/sessions/tmux/index.md
index e0b32c5..39ef398 100644
--- a/docs/getting-started/sessions/tmux/index.md
+++ b/docs/getting-started/sessions/tmux/index.md
@@ -6,6 +6,8 @@ layout: doc
Operator uses [**tmux**](https://github.com/tmux/tmux/wiki){:target="_blank"} for terminal session management, providing a customized experience for managing multiple LLM tool agent sessions from a single terminal.
+> **Note:** For most users, the [VS Code Extension](/getting-started/sessions/vscode/) is now the recommended session manager. It provides an integrated experience that works on all platforms including Windows. The `tmux` interface remains ideal for headless/server environments, SSH sessions, and for use from containerized terminal-centric workflows.
+
## What is `tmux`?
tmux is a program which runs in a terminal and allows multiple other terminal programs to be run inside it. Each program inside tmux gets its own terminal managed by tmux, which can be accessed from the single terminal where tmux is running - this called multiplexing and tmux is a terminal multiplexer.
diff --git a/docs/getting-started/sessions/vscode.md b/docs/getting-started/sessions/vscode.md
index c0fe3e9..1393e65 100644
--- a/docs/getting-started/sessions/vscode.md
+++ b/docs/getting-started/sessions/vscode.md
@@ -6,9 +6,11 @@ layout: doc
# VS Code Extension
+Recommended
+
Install from VS Code Marketplace
-Operator Terminals brings the Operator multi-agent orchestration experience directly into VS Code with integrated terminal management and ticket tracking.
+Operator Terminals brings the Operator multi-agent orchestration experience directly into VS Code with integrated terminal management and ticket tracking. This is the **recommended session manager** for most users, with full support for **macOS**, **Linux**, and **Windows** (no WSL required).
## Features
diff --git a/docs/index.md b/docs/index.md
index 503a263..f875575 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,35 +1,41 @@
---
title: Operator!
-description: "Operator! is a Rust TUI for orchestrating Claude Code agents across multi-project codebases with kanban-style ticket management."
+description: "Operator! is an application for orchestrating LLM coding assist agents across multi-project codebases with kanban-style ticket management."
layout: doc
---
-Welcome friend! **Operator!** is a Rust TUI application for orchestrating Claude Code (and other llms) agents across multi-project codebases. It combines kanban-style ticket management with LLM tools and tmux terminals to create a powerful workflow for managing AI-assisted development.
+Welcome friend! **Operator!** is an application for orchestrating Claude Code (and other LLM coding assist) agents across multi-project codebases. It combines kanban-style ticket management with LLM tools and tmux terminals to create a powerful workflow for managing AI-assisted development.
## What is Operator!?
Operator! helps you:
-- **Manage ticket queues** - Organize work items by priority (INV, FIX, FEAT, SPIKE)
-- **Launch LLM agents** - Start Claude Code sessions with context from tickets
-- **Track progress** - Monitor agent status and completion in real-time
-- **Coordinate work** - Handle parallelism rules and project dependencies
-- **Enforce Standards** - Define how work gets done, across your many software services
-- **Catalog your Code** - Get the overview of your codebase, composed from project files
+- **Manage ticket queues** - Organize and prioritize ticket shaped work into reproducible workflows.
+- **Launch LLM agents** - Start Claude Code sessions with context from tickets and established software development standards.
+- **Track progress** - Monitor agent status and work completion in real-time, keeping you in focus of what needs to get done.
+- **Parallelize Work** - Work multiple tickets at once, across many code repositories, to get the most of your credits.
+- **Enforce Standards** - Define how work gets done by AI tools, across many defined software services.
+- **Catalog your Code** - Get the overview of your codebase, composed from project files, organized and maintained implicitly.
## Getting Started
1. Install Operator! (downloads page)
-2. Configure your projects (J - project tasks)
-3. Create issuetypes to make new work (w - ui from portal)
+2. Configure your project management kanban workspaces
+3. Define your work shape issuetypes, and how AI combines them together
3. Create tickets in `.tickets/queue/` (kanban connectors)
4. Launch agents and watch them work (measuring success)
## Quick Links
- [Kanban](/kanban/) - Understand the kanban workflow
-- [Issue Types](/issue-types/) - Learn about INV, FIX, FEAT, and SPIKE
- [LLM Tools](/llm-tools/) - Configure LLM integration
-- [Tickets](/tickets/) - Create and manage tickets
+- [Tickets](/tickets/) - Create and manage work tickets
- [Agents](/agents/) - Agent lifecycle and modes
- [Tmux](/tmux/) - Terminal session management
+
+## Similar, but Worse:
+
+These are tools that are almost as good as Operator, and have inspired this project:
+
+- [Ralph Code](https://github.com/frankbria/ralph-claude-code)
+- [Vibe Kanban](https://www.vibekanban.com/)
\ No newline at end of file
diff --git a/src/agents/launcher/llm_command.rs b/src/agents/launcher/llm_command.rs
index 427b866..7b4c351 100644
--- a/src/agents/launcher/llm_command.rs
+++ b/src/agents/launcher/llm_command.rs
@@ -176,18 +176,29 @@ fn generate_config_flags(
cli_flags.push(mode_str.to_string());
}
- // Add worktrees directory to allowed directories (bypasses trust dialog)
+ // Add worktrees base directory to allowed directories
let worktrees_path = config.worktrees_path();
cli_flags.push("--add-dir".to_string());
cli_flags.push(worktrees_path.to_string_lossy().to_string());
+ // Add the specific working directory (worktree path) to bypass CWD trust dialog
+ // Claude Code checks if the CWD is trusted, not just parent directories
+ cli_flags.push("--add-dir".to_string());
+ cli_flags.push(project_path.to_string());
+
// Add JSON schema flag for structured output
+ // Write schema to a file to avoid shell escaping issues with inline JSON
// Inline jsonSchema takes precedence over jsonSchemaFile
if let Some(ref schema) = step_config.json_schema {
+ // Write inline schema to session_dir/schema.json
+ let schema_file_path = session_dir.join("schema.json");
let schema_str =
- serde_json::to_string(schema).context("Failed to serialize JSON schema")?;
+ serde_json::to_string_pretty(schema).context("Failed to serialize JSON schema")?;
+ fs::write(&schema_file_path, &schema_str).with_context(|| {
+ format!("Failed to write JSON schema file: {:?}", schema_file_path)
+ })?;
cli_flags.push("--json-schema".to_string());
- cli_flags.push(schema_str);
+ cli_flags.push(schema_file_path.to_string_lossy().to_string());
} else if let Some(ref schema_file) = step_config.json_schema_file {
// Resolve path - .tickets/ paths are relative to tickets parent dir, others to project
let schema_path =
@@ -200,10 +211,12 @@ fn generate_config_flags(
} else {
PathBuf::from(project_path).join(schema_file)
};
- let schema_content = fs::read_to_string(&schema_path)
- .with_context(|| format!("Failed to read JSON schema file: {:?}", schema_path))?;
+ // Verify schema file exists, then pass the path (not content)
+ if !schema_path.exists() {
+ anyhow::bail!("JSON schema file not found: {:?}", schema_path);
+ }
cli_flags.push("--json-schema".to_string());
- cli_flags.push(schema_content);
+ cli_flags.push(schema_path.to_string_lossy().to_string());
}
}
@@ -911,4 +924,109 @@ mod tests {
);
}
}
+
+ // ========================================
+ // JSON schema file path tests
+ // ========================================
+ mod json_schema {
+ use std::path::PathBuf;
+ use tempfile::TempDir;
+
+ /// Test helper: verify that a JSON schema file is written correctly
+ #[test]
+ fn test_json_schema_written_to_file_is_valid_json() {
+ use serde_json::json;
+
+ let temp_dir = TempDir::new().unwrap();
+ let schema_path = temp_dir.path().join("schema.json");
+
+ // Simulate what generate_config_flags does for inline schema
+ let schema = json!({
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "properties": {
+ "summary": { "type": "string" },
+ "items": { "type": "array" }
+ },
+ "required": ["summary"]
+ });
+
+ let schema_str = serde_json::to_string_pretty(&schema).unwrap();
+ std::fs::write(&schema_path, &schema_str).unwrap();
+
+ // Verify the file was written and is valid JSON
+ let content = std::fs::read_to_string(&schema_path).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert_eq!(parsed["type"], "object");
+ assert!(parsed["properties"]["summary"].is_object());
+ }
+
+ /// Test that file path string conversion preserves the path
+ #[test]
+ fn test_schema_file_path_to_string() {
+ let path = PathBuf::from("/tmp/test/.tickets/operator/sessions/TEST-001/schema.json");
+ let path_str = path.to_string_lossy().to_string();
+
+ assert!(path_str.contains("schema.json"));
+ assert!(path_str.contains("sessions"));
+ assert!(!path_str.contains("{")); // No JSON content
+ }
+
+ /// Test that json_schema_file path existence check works
+ #[test]
+ fn test_schema_file_path_exists_check() {
+ let temp_dir = TempDir::new().unwrap();
+
+ // Create a schema file
+ let schema_path = temp_dir.path().join("test.schema.json");
+ std::fs::write(&schema_path, r#"{"type": "object"}"#).unwrap();
+
+ // Exists check should pass
+ assert!(schema_path.exists());
+
+ // Non-existent path should fail
+ let missing_path = temp_dir.path().join("nonexistent.json");
+ assert!(!missing_path.exists());
+ }
+
+ /// Test that a complex JSON schema with special characters is handled correctly
+ #[test]
+ fn test_complex_schema_with_special_chars() {
+ use serde_json::json;
+
+ let temp_dir = TempDir::new().unwrap();
+ let schema_path = temp_dir.path().join("schema.json");
+
+ // Schema with characters that would break shell if passed inline
+ let schema = json!({
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "title": "Test Schema with \"quotes\" and 'apostrophes'",
+ "description": "Contains special chars: $HOME, `backticks`, $(subshell)",
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string",
+ "pattern": "^[a-z]+\\{.*\\}$"
+ }
+ }
+ });
+
+ let schema_str = serde_json::to_string_pretty(&schema).unwrap();
+ std::fs::write(&schema_path, &schema_str).unwrap();
+
+ // Verify the path is simple and safe for shell
+ let path_str = schema_path.to_string_lossy().to_string();
+ assert!(!path_str.contains('\n'));
+ assert!(!path_str.contains("\""));
+ assert!(!path_str.contains("'"));
+
+ // Verify content is preserved
+ let content = std::fs::read_to_string(&schema_path).unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
+ assert!(parsed["description"]
+ .as_str()
+ .unwrap()
+ .contains("$(subshell)"));
+ }
+ }
}
diff --git a/src/agents/launcher/prompt.rs b/src/agents/launcher/prompt.rs
index 23cd62d..4fd7141 100644
--- a/src/agents/launcher/prompt.rs
+++ b/src/agents/launcher/prompt.rs
@@ -146,6 +146,43 @@ pub fn write_prompt_file(config: &Config, session_uuid: &str, prompt: &str) -> R
Ok(prompt_file)
}
+/// Write a shell command to an executable script file and return the path
+/// Commands are stored in .tickets/operator/commands/{session_uuid}.sh
+///
+/// This solves issues with long commands and special characters when using tmux send-keys.
+/// Instead of pasting complex commands directly, we write them to a script and execute that.
+pub fn write_command_file(
+ config: &Config,
+ session_uuid: &str,
+ project_path: &str,
+ llm_command: &str,
+) -> Result {
+ let commands_dir = config.tickets_path().join("operator/commands");
+ fs::create_dir_all(&commands_dir).context("Failed to create commands directory")?;
+
+ let command_file = commands_dir.join(format!("{}.sh", session_uuid));
+
+ // Build script content with shebang, cd, and exec
+ let script_content = format!(
+ "#!/bin/bash\ncd {}\nexec {}\n",
+ shell_escape(project_path),
+ llm_command
+ );
+
+ fs::write(&command_file, &script_content).context("Failed to write command file")?;
+
+ // Make the file executable on Unix systems
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let permissions = fs::Permissions::from_mode(0o755);
+ fs::set_permissions(&command_file, permissions)
+ .context("Failed to set command file permissions")?;
+ }
+
+ Ok(command_file)
+}
+
/// Escape a string for safe use in shell command
pub fn shell_escape(s: &str) -> String {
// Use single quotes and escape any single quotes within
@@ -200,4 +237,127 @@ mod tests {
assert_ne!(uuid2, uuid3);
assert_ne!(uuid1, uuid3);
}
+
+ fn make_test_config_with_tickets_path(tickets_path: &std::path::Path) -> Config {
+ use crate::config::PathsConfig;
+
+ Config {
+ paths: PathsConfig {
+ tickets: tickets_path.to_string_lossy().to_string(),
+ projects: tickets_path.parent().unwrap().to_string_lossy().to_string(),
+ state: tickets_path.join("operator").to_string_lossy().to_string(),
+ worktrees: tickets_path
+ .join("operator/worktrees")
+ .to_string_lossy()
+ .to_string(),
+ },
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn test_write_command_file_creates_file_with_correct_content() {
+ use tempfile::tempdir;
+
+ // Create a temp directory to act as our tickets path
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let session_uuid = "test-uuid-1234";
+ let project_path = "/path/to/project";
+ let llm_command = "claude --session-id abc123 --print-prompt-path /tmp/prompt.txt";
+
+ let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ assert!(result.is_ok());
+
+ let command_file = result.unwrap();
+ assert!(command_file.exists());
+ assert_eq!(command_file.file_name().unwrap(), "test-uuid-1234.sh");
+
+ let content = std::fs::read_to_string(&command_file).unwrap();
+ assert!(content.starts_with("#!/bin/bash\n"));
+ assert!(content.contains("cd '/path/to/project'"));
+ assert!(content.contains("exec claude --session-id abc123"));
+ }
+
+ #[test]
+ fn test_write_command_file_handles_spaces_in_path() {
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let session_uuid = "test-uuid-spaces";
+ let project_path = "/path/with spaces/to/project";
+ let llm_command = "claude --arg value";
+
+ let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ assert!(result.is_ok());
+
+ let content = std::fs::read_to_string(result.unwrap()).unwrap();
+ // Path with spaces should be properly escaped with single quotes
+ assert!(content.contains("cd '/path/with spaces/to/project'"));
+ }
+
+ #[test]
+ fn test_write_command_file_handles_special_chars_in_path() {
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let session_uuid = "test-uuid-special";
+ let project_path = "/path/with'quotes/and$dollar";
+ let llm_command = "claude --arg value";
+
+ let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ assert!(result.is_ok());
+
+ let content = std::fs::read_to_string(result.unwrap()).unwrap();
+ // Single quotes in path should be escaped properly
+ assert!(content.contains("cd '/path/with'\"'\"'quotes/and$dollar'"));
+ }
+
+ #[test]
+ fn test_write_command_file_preserves_complex_commands() {
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let session_uuid = "test-uuid-complex";
+ let project_path = "/project";
+ let llm_command = r#"claude --session-id abc --print-prompt-path /tmp/file.txt --add-dir "/dir with spaces" --model sonnet"#;
+
+ let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ assert!(result.is_ok());
+
+ let content = std::fs::read_to_string(result.unwrap()).unwrap();
+ // The full command should be preserved exactly
+ assert!(content.contains(llm_command));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn test_write_command_file_is_executable() {
+ use std::os::unix::fs::PermissionsExt;
+ use tempfile::tempdir;
+
+ let temp_dir = tempdir().unwrap();
+ let config = make_test_config_with_tickets_path(temp_dir.path());
+
+ let session_uuid = "test-uuid-executable";
+ let project_path = "/project";
+ let llm_command = "claude --arg value";
+
+ let result = write_command_file(&config, session_uuid, project_path, llm_command);
+ assert!(result.is_ok());
+
+ let command_file = result.unwrap();
+ let metadata = std::fs::metadata(&command_file).unwrap();
+ let permissions = metadata.permissions();
+
+ // Check that the file is executable (0o755 = rwxr-xr-x)
+ assert_eq!(permissions.mode() & 0o777, 0o755);
+ }
}
diff --git a/src/agents/launcher/tests.rs b/src/agents/launcher/tests.rs
index 88595b1..4e2d873 100644
--- a/src/agents/launcher/tests.rs
+++ b/src/agents/launcher/tests.rs
@@ -12,6 +12,15 @@ use crate::queue::Ticket;
use super::prompt::{generate_session_uuid, shell_escape};
use super::{LaunchOptions, Launcher, SESSION_PREFIX};
+/// Helper to read the command file content from a bash command sent to tmux.
+/// The command format is: `bash /path/to/script.sh`
+fn read_command_file_content(sent_cmd: &str) -> Option {
+ // Extract the script path from "bash /path/to/script.sh [Enter]"
+ let cmd = sent_cmd.trim_end_matches(" [Enter]");
+ let path = cmd.strip_prefix("bash ")?;
+ std::fs::read_to_string(path).ok()
+}
+
fn make_test_config(temp_dir: &TempDir) -> Config {
let projects_path = temp_dir.path().join("projects");
let tickets_path = temp_dir.path().join("tickets");
@@ -432,18 +441,22 @@ async fn test_launch_command_includes_cd_to_project() {
let keys = keys_sent.unwrap();
assert!(!keys.is_empty(), "At least one command should be sent");
- // The command should start with cd to the project path
+ // The sent command should be a bash script call, read the script content
+ let sent_cmd = &keys[0];
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
+
+ // The script should contain cd to the project path
let expected_path = temp_dir
.path()
.join("projects")
.join("test-project")
.to_string_lossy()
.to_string();
- let cmd = &keys[0];
assert!(
- cmd.contains(&format!("cd '{}'", expected_path)),
- "Command should include cd to project path, got: {}",
- cmd
+ script_content.contains(&format!("cd '{}'", expected_path)),
+ "Command file should include cd to project path, got: {}",
+ script_content
);
}
@@ -599,11 +612,22 @@ fn test_launch_in_tmux_sends_cd_command() {
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
assert!(keys_sent.is_some(), "Keys should have been sent");
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Command should be a bash script execution
assert!(
- cmd.starts_with("cd "),
- "Command should start with cd, got: {}",
- cmd
+ sent_cmd.starts_with("bash "),
+ "Command should be a bash script execution, got: {}",
+ sent_cmd
+ );
+
+ // Read the script content and verify it contains cd
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
+ assert!(
+ script_content.contains("cd "),
+ "Command file should contain cd, got: {}",
+ script_content
);
}
@@ -634,16 +658,20 @@ fn test_launch_in_tmux_sends_llm_command() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains the LLM command
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("claude"),
- "Command should contain claude, got: {}",
- cmd
+ script_content.contains("claude"),
+ "Command file should contain claude, got: {}",
+ script_content
);
assert!(
- cmd.contains("--session-id"),
- "Command should contain --session-id, got: {}",
- cmd
+ script_content.contains("--session-id"),
+ "Command file should contain --session-id, got: {}",
+ script_content
);
}
@@ -677,11 +705,15 @@ fn test_launch_in_tmux_yolo_mode_applies_flags() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains the YOLO flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("--dangerously-skip-permissions"),
- "Command should contain YOLO flag, got: {}",
- cmd
+ script_content.contains("--dangerously-skip-permissions"),
+ "Command file should contain YOLO flag, got: {}",
+ script_content
);
}
@@ -715,11 +747,15 @@ fn test_launch_in_tmux_yolo_mode_disabled_no_flags() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it does NOT contain the YOLO flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- !cmd.contains("--dangerously-skip-permissions"),
- "Command should NOT contain YOLO flag when disabled, got: {}",
- cmd
+ !script_content.contains("--dangerously-skip-permissions"),
+ "Command file should NOT contain YOLO flag when disabled, got: {}",
+ script_content
);
}
@@ -753,11 +789,15 @@ fn test_launch_in_tmux_docker_mode_wraps() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains docker run
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("docker run"),
- "Command should contain docker run, got: {}",
- cmd
+ script_content.contains("docker run"),
+ "Command file should contain docker run, got: {}",
+ script_content
);
}
@@ -792,16 +832,20 @@ fn test_launch_in_tmux_both_modes() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains both docker and YOLO flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("docker run"),
- "Command should contain docker run, got: {}",
- cmd
+ script_content.contains("docker run"),
+ "Command file should contain docker run, got: {}",
+ script_content
);
assert!(
- cmd.contains("--dangerously-skip-permissions"),
- "Command should contain YOLO flag, got: {}",
- cmd
+ script_content.contains("--dangerously-skip-permissions"),
+ "Command file should contain YOLO flag, got: {}",
+ script_content
);
}
@@ -853,16 +897,20 @@ fn test_launch_in_tmux_uses_provider_from_options() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it uses the gemini tool
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("gemini"),
- "Command should use gemini tool, got: {}",
- cmd
+ script_content.contains("gemini"),
+ "Command file should use gemini tool, got: {}",
+ script_content
);
assert!(
- cmd.contains("--model pro"),
- "Command should use pro model, got: {}",
- cmd
+ script_content.contains("--model pro"),
+ "Command file should use pro model, got: {}",
+ script_content
);
}
@@ -1011,11 +1059,15 @@ fn test_relaunch_inherits_yolo_mode() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains the YOLO flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("--dangerously-skip-permissions"),
- "Relaunch should apply YOLO flags, got: {}",
- cmd
+ script_content.contains("--dangerously-skip-permissions"),
+ "Relaunch command file should apply YOLO flags, got: {}",
+ script_content
);
}
@@ -1053,11 +1105,15 @@ fn test_relaunch_inherits_docker_mode() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains docker run
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("docker run"),
- "Relaunch should apply Docker wrapping, got: {}",
- cmd
+ script_content.contains("docker run"),
+ "Relaunch command file should apply Docker wrapping, got: {}",
+ script_content
);
}
@@ -1145,16 +1201,20 @@ fn test_relaunch_with_resume_adds_flag() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it contains the resume flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
assert!(
- cmd.contains("--resume"),
- "Resume mode should add --resume flag, got: {}",
- cmd
+ script_content.contains("--resume"),
+ "Resume mode command file should add --resume flag, got: {}",
+ script_content
);
assert!(
- cmd.contains(resume_uuid),
+ script_content.contains(resume_uuid),
"Resume should use the provided session ID, got: {}",
- cmd
+ script_content
);
}
@@ -1192,11 +1252,15 @@ fn test_relaunch_missing_prompt_fresh_start() {
assert!(result.is_ok());
let session_name = result.unwrap();
let keys_sent = mock.get_session_keys_sent(&session_name);
- let cmd = &keys_sent.unwrap()[0];
+ let sent_cmd = &keys_sent.unwrap()[0];
+
+ // Read the script content and verify it does NOT have resume flag
+ let script_content =
+ read_command_file_content(sent_cmd).expect("Should be able to read command file content");
// Should NOT have resume flag since prompt file doesn't exist
assert!(
- !cmd.contains("--resume"),
+ !script_content.contains("--resume"),
"Should fall back to fresh start when prompt file missing, got: {}",
- cmd
+ script_content
);
}
diff --git a/src/agents/launcher/tmux_session.rs b/src/agents/launcher/tmux_session.rs
index 2967d69..0510d1a 100644
--- a/src/agents/launcher/tmux_session.rs
+++ b/src/agents/launcher/tmux_session.rs
@@ -15,7 +15,8 @@ use super::llm_command::{
};
use super::options::{LaunchOptions, RelaunchOptions};
use super::prompt::{
- generate_session_uuid, get_agent_prompt, get_template_prompt, shell_escape, write_prompt_file,
+ generate_session_uuid, get_agent_prompt, get_template_prompt, write_command_file,
+ write_prompt_file,
};
use super::SESSION_PREFIX;
@@ -180,11 +181,13 @@ pub fn launch_in_tmux_with_options(
llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
}
- // Prepend cd to ensure correct working directory (belt and suspenders with tmux -c)
- let full_cmd = format!("cd {} && {}", shell_escape(project_path), llm_cmd);
+ // Write the command to a shell script file to avoid issues with long commands
+ // and special characters when using tmux send-keys
+ let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
- // Send the LLM command to the session
- if let Err(e) = tmux.send_keys(&session_name, &full_cmd, true) {
+ // Send simple bash command to execute the script (always short, so no buffer needed)
+ let bash_cmd = format!("bash {}", command_file.display());
+ if let Err(e) = tmux.send_keys(&session_name, &bash_cmd, true) {
// Clean up the session if we couldn't send the command
let _ = tmux.kill_session(&session_name);
anyhow::bail!("Failed to start LLM agent in tmux session: {}", e);
@@ -199,6 +202,7 @@ pub fn launch_in_tmux_with_options(
tool = %tool_name,
launch_mode = %options.launch_mode_string(),
working_dir = %project_path,
+ command_file = %command_file.display(),
"Launched agent in tmux session"
);
@@ -396,11 +400,13 @@ pub fn launch_in_tmux_with_relaunch_options(
llm_cmd = build_docker_command(config, &llm_cmd, project_path)?;
}
- // Prepend cd to ensure correct working directory (belt and suspenders with tmux -c)
- let full_cmd = format!("cd {} && {}", shell_escape(project_path), llm_cmd);
+ // Write the command to a shell script file to avoid issues with long commands
+ // and special characters when using tmux send-keys
+ let command_file = write_command_file(config, &session_uuid, project_path, &llm_cmd)?;
- // Send the LLM command to the session
- if let Err(e) = tmux.send_keys(&session_name, &full_cmd, true) {
+ // Send simple bash command to execute the script (always short, so no buffer needed)
+ let bash_cmd = format!("bash {}", command_file.display());
+ if let Err(e) = tmux.send_keys(&session_name, &bash_cmd, true) {
// Clean up the session if we couldn't send the command
let _ = tmux.kill_session(&session_name);
anyhow::bail!("Failed to start LLM agent in tmux session: {}", e);
@@ -416,6 +422,7 @@ pub fn launch_in_tmux_with_relaunch_options(
is_resume = %is_resume,
launch_mode = %options.launch_options.launch_mode_string(),
working_dir = %project_path,
+ command_file = %command_file.display(),
"Relaunched agent in tmux session"
);
diff --git a/src/agents/tmux.rs b/src/agents/tmux.rs
index 15fc9f9..e0132e7 100644
--- a/src/agents/tmux.rs
+++ b/src/agents/tmux.rs
@@ -40,6 +40,15 @@ pub enum TmuxError {
#[error("failed to send keys to session '{0}': {1}")]
SendKeysFailed(String, String),
+ #[error("failed to set buffer '{0}': {1}")]
+ SetBufferFailed(String, String),
+
+ #[error("failed to paste buffer '{0}' to session '{1}': {2}")]
+ PasteBufferFailed(String, String, String),
+
+ #[error("failed to delete buffer '{0}': {1}")]
+ DeleteBufferFailed(String, String),
+
#[error("tmux command failed: {0}")]
CommandFailed(String),
}
@@ -146,6 +155,24 @@ pub trait TmuxClient: Send + Sync {
/// Attach to session with detach hook that creates a signal file
/// Returns the path to the signal file that will be created on detach
fn attach_session_with_detach_signal(&self, session: &str) -> Result;
+
+ /// Set a named tmux buffer with the given content
+ fn set_buffer(&self, buffer_name: &str, content: &str) -> Result<(), TmuxError>;
+
+ /// Paste a named buffer to a session
+ fn paste_buffer(&self, buffer_name: &str, session: &str) -> Result<(), TmuxError>;
+
+ /// Delete a named buffer
+ fn delete_buffer(&self, buffer_name: &str) -> Result<(), TmuxError>;
+
+ /// Send a command to a session via buffer (bypasses send-keys length limit)
+ /// Uses: set-buffer -> paste-buffer -> send Enter -> delete-buffer
+ fn send_command_via_buffer(&self, session: &str, command: &str) -> Result<(), TmuxError>;
+
+ /// Safe version of send_keys that automatically uses buffer for long commands
+ /// Commands over SEND_KEYS_THRESHOLD bytes use the buffer method to avoid tmux limits
+ fn send_keys_safe(&self, session: &str, keys: &str, press_enter: bool)
+ -> Result<(), TmuxError>;
}
/// Real implementation using system tmux
@@ -160,6 +187,10 @@ pub struct SystemTmuxClient {
/// Default socket name for operator-managed tmux sessions
pub const OPERATOR_SOCKET: &str = "operator";
+/// Threshold in bytes for switching from send_keys to buffer method
+/// tmux 1.8 has ~2KB limit, tmux 1.9+ has ~16KB limit. Use conservative value.
+const SEND_KEYS_THRESHOLD: usize = 2000;
+
impl SystemTmuxClient {
/// Create a new client using default tmux config and socket
pub fn new() -> Self {
@@ -509,6 +540,143 @@ impl TmuxClient for SystemTmuxClient {
Ok(signal_file)
}
+
+ fn set_buffer(&self, buffer_name: &str, content: &str) -> Result<(), TmuxError> {
+ use std::io::Write;
+ use std::process::Stdio;
+
+ // Use load-buffer with stdin ("-") to avoid CLI argument length limits (ARG_MAX).
+ // tmux load-buffer reads from path, where "-" means stdin.
+ // This allows setting buffers with content of any size.
+ let mut cmd = Command::new("tmux");
+
+ // Use dedicated socket if configured (must come before -f)
+ if let Some(ref socket) = self.socket_name {
+ cmd.arg("-L").arg(socket);
+ }
+
+ // If custom config is set, add -f flag
+ if let Some(ref config_path) = self.config_path {
+ cmd.arg("-f").arg(config_path);
+ }
+
+ cmd.args(["load-buffer", "-b", buffer_name, "-"])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+
+ let mut child = cmd.spawn().map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ TmuxError::NotInstalled
+ } else {
+ TmuxError::CommandFailed(e.to_string())
+ }
+ })?;
+
+ // Write content to stdin
+ if let Some(mut stdin) = child.stdin.take() {
+ stdin
+ .write_all(content.as_bytes())
+ .map_err(|e| TmuxError::SetBufferFailed(buffer_name.to_string(), e.to_string()))?;
+ }
+
+ let output = child
+ .wait_with_output()
+ .map_err(|e| TmuxError::SetBufferFailed(buffer_name.to_string(), e.to_string()))?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(TmuxError::SetBufferFailed(
+ buffer_name.to_string(),
+ stderr.to_string(),
+ ));
+ }
+
+ Ok(())
+ }
+
+ fn paste_buffer(&self, buffer_name: &str, session: &str) -> Result<(), TmuxError> {
+ let output = self.run_tmux(&["paste-buffer", "-b", buffer_name, "-t", session])?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(TmuxError::PasteBufferFailed(
+ buffer_name.to_string(),
+ session.to_string(),
+ stderr.to_string(),
+ ));
+ }
+
+ Ok(())
+ }
+
+ fn delete_buffer(&self, buffer_name: &str) -> Result<(), TmuxError> {
+ let output = self.run_tmux(&["delete-buffer", "-b", buffer_name])?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(TmuxError::DeleteBufferFailed(
+ buffer_name.to_string(),
+ stderr.to_string(),
+ ));
+ }
+
+ Ok(())
+ }
+
+ fn send_command_via_buffer(&self, session: &str, command: &str) -> Result<(), TmuxError> {
+ // Use unique buffer name to avoid conflicts with concurrent launches
+ let buffer_name = format!("op-cmd-{}", session);
+
+ // Set buffer with the command
+ self.set_buffer(&buffer_name, command)?;
+
+ // Paste buffer to session (cleanup buffer on failure)
+ if let Err(e) = self.paste_buffer(&buffer_name, session) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ // Send Enter to execute the command (cleanup buffer on failure)
+ if let Err(e) = self.send_keys(session, "", true) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ // Clean up the buffer
+ let _ = self.delete_buffer(&buffer_name);
+
+ Ok(())
+ }
+
+ fn send_keys_safe(
+ &self,
+ session: &str,
+ keys: &str,
+ press_enter: bool,
+ ) -> Result<(), TmuxError> {
+ if keys.len() > SEND_KEYS_THRESHOLD {
+ // Long command: use buffer method
+ if press_enter {
+ self.send_command_via_buffer(session, keys)
+ } else {
+ // For non-enter case with long content, still use buffer but don't press enter
+ let buffer_name = format!("op-cmd-{}", session);
+ self.set_buffer(&buffer_name, keys)?;
+
+ if let Err(e) = self.paste_buffer(&buffer_name, session) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ let _ = self.delete_buffer(&buffer_name);
+ Ok(())
+ }
+ } else {
+ // Short command: use regular send_keys
+ self.send_keys(session, keys, press_enter)
+ }
+ }
}
/// Mock implementation for testing
@@ -516,6 +684,8 @@ impl TmuxClient for SystemTmuxClient {
pub struct MockTmuxClient {
/// Simulated sessions: name -> (working_dir, content, attached)
sessions: Arc>>,
+ /// Simulated buffers: buffer_name -> content
+ buffers: Arc>>,
/// Whether tmux is "installed"
pub installed: Arc>,
/// Version to report
@@ -553,6 +723,7 @@ impl MockTmuxClient {
pub fn new() -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
+ buffers: Arc::new(Mutex::new(HashMap::new())),
installed: Arc::new(Mutex::new(true)),
version: Arc::new(Mutex::new(Some(TmuxVersion {
major: 3,
@@ -912,6 +1083,129 @@ impl TmuxClient for MockTmuxClient {
Err(TmuxError::SessionNotFound(session.to_string()))
}
}
+
+ fn set_buffer(&self, buffer_name: &str, content: &str) -> Result<(), TmuxError> {
+ self.log_command("set_buffer", &[buffer_name, content]);
+
+ if !*self.installed.lock().unwrap() {
+ return Err(TmuxError::NotInstalled);
+ }
+
+ self.buffers
+ .lock()
+ .unwrap()
+ .insert(buffer_name.to_string(), content.to_string());
+ Ok(())
+ }
+
+ fn paste_buffer(&self, buffer_name: &str, session: &str) -> Result<(), TmuxError> {
+ self.log_command("paste_buffer", &[buffer_name, session]);
+
+ if !*self.installed.lock().unwrap() {
+ return Err(TmuxError::NotInstalled);
+ }
+
+ // Check if buffer exists
+ let buffers = self.buffers.lock().unwrap();
+ let content = buffers.get(buffer_name).ok_or_else(|| {
+ TmuxError::PasteBufferFailed(
+ buffer_name.to_string(),
+ session.to_string(),
+ "buffer not found".to_string(),
+ )
+ })?;
+ let content = content.clone();
+ drop(buffers);
+
+ // Check if session exists and paste content
+ let mut sessions = self.sessions.lock().unwrap();
+ if let Some(s) = sessions.get_mut(session) {
+ // In mock, pasting buffer is like sending keys without Enter
+ s.keys_sent
+ .push(format!("[buffer:{}] {}", buffer_name, content));
+ Ok(())
+ } else {
+ Err(TmuxError::SessionNotFound(session.to_string()))
+ }
+ }
+
+ fn delete_buffer(&self, buffer_name: &str) -> Result<(), TmuxError> {
+ self.log_command("delete_buffer", &[buffer_name]);
+
+ if !*self.installed.lock().unwrap() {
+ return Err(TmuxError::NotInstalled);
+ }
+
+ self.buffers.lock().unwrap().remove(buffer_name);
+ Ok(())
+ }
+
+ fn send_command_via_buffer(&self, session: &str, command: &str) -> Result<(), TmuxError> {
+ self.log_command("send_command_via_buffer", &[session, command]);
+
+ if !*self.installed.lock().unwrap() {
+ return Err(TmuxError::NotInstalled);
+ }
+
+ let buffer_name = format!("op-cmd-{}", session);
+
+ // Set buffer
+ self.set_buffer(&buffer_name, command)?;
+
+ // Paste buffer (cleanup on failure)
+ if let Err(e) = self.paste_buffer(&buffer_name, session) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ // Send Enter
+ if let Err(e) = self.send_keys(session, "", true) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ // Clean up buffer
+ let _ = self.delete_buffer(&buffer_name);
+
+ Ok(())
+ }
+
+ fn send_keys_safe(
+ &self,
+ session: &str,
+ keys: &str,
+ press_enter: bool,
+ ) -> Result<(), TmuxError> {
+ self.log_command(
+ "send_keys_safe",
+ &[session, keys, if press_enter { "Enter" } else { "" }],
+ );
+
+ if !*self.installed.lock().unwrap() {
+ return Err(TmuxError::NotInstalled);
+ }
+
+ if keys.len() > SEND_KEYS_THRESHOLD {
+ // Long command: use buffer method
+ if press_enter {
+ self.send_command_via_buffer(session, keys)
+ } else {
+ let buffer_name = format!("op-cmd-{}", session);
+ self.set_buffer(&buffer_name, keys)?;
+
+ if let Err(e) = self.paste_buffer(&buffer_name, session) {
+ let _ = self.delete_buffer(&buffer_name);
+ return Err(e);
+ }
+
+ let _ = self.delete_buffer(&buffer_name);
+ Ok(())
+ }
+ } else {
+ // Short command: use regular send_keys
+ self.send_keys(session, keys, press_enter)
+ }
+ }
}
/// Sanitize a string for use as a tmux session name
@@ -1391,4 +1685,328 @@ mod tests {
let result = client.attach_session_with_detach_signal("nonexistent");
assert!(matches!(result, Err(TmuxError::SessionNotFound(_))));
}
+
+ // ========================================================================
+ // Buffer method tests
+ // ========================================================================
+
+ #[test]
+ fn test_set_buffer_stores_content() {
+ let client = MockTmuxClient::new();
+
+ client.set_buffer("test-buf", "hello world").unwrap();
+
+ let buffers = client.buffers.lock().unwrap();
+ assert_eq!(buffers.get("test-buf"), Some(&"hello world".to_string()));
+ }
+
+ #[test]
+ fn test_paste_buffer_to_session() {
+ let client = MockTmuxClient::new();
+
+ // Create session and buffer
+ client.create_session("test-session", "/tmp").unwrap();
+ client.set_buffer("test-buf", "echo hello").unwrap();
+
+ // Paste buffer
+ client.paste_buffer("test-buf", "test-session").unwrap();
+
+ // Verify it was recorded in session's keys_sent
+ let keys = client.get_session_keys_sent("test-session").unwrap();
+ assert!(keys.iter().any(|k| k.contains("echo hello")));
+ }
+
+ #[test]
+ fn test_paste_buffer_nonexistent_buffer() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ let result = client.paste_buffer("nonexistent-buf", "test-session");
+ assert!(matches!(result, Err(TmuxError::PasteBufferFailed(_, _, _))));
+ }
+
+ #[test]
+ fn test_paste_buffer_nonexistent_session() {
+ let client = MockTmuxClient::new();
+
+ client.set_buffer("test-buf", "content").unwrap();
+
+ let result = client.paste_buffer("test-buf", "nonexistent-session");
+ assert!(matches!(result, Err(TmuxError::SessionNotFound(_))));
+ }
+
+ #[test]
+ fn test_delete_buffer_removes_content() {
+ let client = MockTmuxClient::new();
+
+ client.set_buffer("test-buf", "content").unwrap();
+ assert!(client.buffers.lock().unwrap().contains_key("test-buf"));
+
+ client.delete_buffer("test-buf").unwrap();
+ assert!(!client.buffers.lock().unwrap().contains_key("test-buf"));
+ }
+
+ #[test]
+ fn test_send_command_via_buffer() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Send a command via buffer
+ client
+ .send_command_via_buffer("test-session", "echo 'hello world'")
+ .unwrap();
+
+ // Verify the command was logged
+ let commands = client.get_commands();
+ assert!(commands
+ .iter()
+ .any(|c| c.operation == "send_command_via_buffer"));
+
+ // Verify buffer was cleaned up
+ let buffers = client.buffers.lock().unwrap();
+ assert!(!buffers.contains_key("op-cmd-test-session"));
+ }
+
+ #[test]
+ fn test_send_keys_safe_short_command_uses_send_keys() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Short command (under threshold)
+ let short_cmd = "echo hello";
+ client
+ .send_keys_safe("test-session", short_cmd, true)
+ .unwrap();
+
+ // Should have used regular send_keys, not buffer
+ let commands = client.get_commands();
+ let has_send_keys = commands.iter().any(|c| c.operation == "send_keys");
+ let has_buffer = commands.iter().any(|c| c.operation == "set_buffer");
+ assert!(has_send_keys);
+ assert!(!has_buffer);
+ }
+
+ #[test]
+ fn test_send_keys_safe_long_command_uses_buffer() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Long command (over threshold of 2000 bytes)
+ let long_cmd = "x".repeat(2500);
+ client
+ .send_keys_safe("test-session", &long_cmd, true)
+ .unwrap();
+
+ // Should have used buffer method
+ let commands = client.get_commands();
+ let has_buffer = commands.iter().any(|c| c.operation == "set_buffer");
+ assert!(has_buffer);
+ }
+
+ #[test]
+ fn test_send_keys_safe_very_long_command_10kb() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Very long command (10KB)
+ let very_long_cmd = "y".repeat(10240);
+ client
+ .send_keys_safe("test-session", &very_long_cmd, true)
+ .unwrap();
+
+ // Should have used buffer method
+ let commands = client.get_commands();
+ let has_buffer = commands.iter().any(|c| c.operation == "set_buffer");
+ assert!(has_buffer);
+
+ // Buffer should be cleaned up after
+ let buffers = client.buffers.lock().unwrap();
+ assert!(buffers.is_empty());
+ }
+
+ #[test]
+ fn test_send_keys_safe_threshold_boundary() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Exactly at threshold (2000 bytes) - should use send_keys
+ let at_threshold = "a".repeat(2000);
+ client
+ .send_keys_safe("test-session", &at_threshold, true)
+ .unwrap();
+
+ let commands = client.get_commands();
+ // Count operations - should have send_keys but no buffer for at-threshold
+ let send_keys_count = commands
+ .iter()
+ .filter(|c| c.operation == "send_keys")
+ .count();
+ let buffer_count = commands
+ .iter()
+ .filter(|c| c.operation == "set_buffer")
+ .count();
+
+ // At 2000 bytes (not over), should use send_keys
+ assert!(send_keys_count > 0);
+ assert_eq!(buffer_count, 0);
+ }
+
+ #[test]
+ fn test_send_keys_safe_just_over_threshold() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Just over threshold (2001 bytes) - should use buffer
+ let over_threshold = "b".repeat(2001);
+ client
+ .send_keys_safe("test-session", &over_threshold, true)
+ .unwrap();
+
+ let commands = client.get_commands();
+ let has_buffer = commands.iter().any(|c| c.operation == "set_buffer");
+ assert!(has_buffer);
+ }
+
+ #[test]
+ fn test_buffer_cleanup_on_paste_failure() {
+ let client = MockTmuxClient::new();
+
+ // Don't create session - paste will fail
+ let result = client.send_command_via_buffer("nonexistent", "echo hello");
+ assert!(result.is_err());
+
+ // Buffer should still be cleaned up
+ let buffers = client.buffers.lock().unwrap();
+ assert!(!buffers.contains_key("op-cmd-nonexistent"));
+ }
+
+ #[test]
+ fn test_buffer_methods_not_installed() {
+ let client = MockTmuxClient::not_installed();
+
+ let result = client.set_buffer("buf", "content");
+ assert!(matches!(result, Err(TmuxError::NotInstalled)));
+
+ let result = client.paste_buffer("buf", "session");
+ assert!(matches!(result, Err(TmuxError::NotInstalled)));
+
+ let result = client.delete_buffer("buf");
+ assert!(matches!(result, Err(TmuxError::NotInstalled)));
+
+ let result = client.send_command_via_buffer("session", "cmd");
+ assert!(matches!(result, Err(TmuxError::NotInstalled)));
+
+ let result = client.send_keys_safe("session", "keys", true);
+ assert!(matches!(result, Err(TmuxError::NotInstalled)));
+ }
+
+ #[test]
+ fn test_send_keys_safe_without_enter() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Long command without pressing enter
+ let long_cmd = "z".repeat(2500);
+ client
+ .send_keys_safe("test-session", &long_cmd, false)
+ .unwrap();
+
+ // Should have used buffer but not sent Enter
+ let commands = client.get_commands();
+ let has_buffer = commands.iter().any(|c| c.operation == "set_buffer");
+ assert!(has_buffer);
+
+ // send_command_via_buffer should NOT have been called (that always presses Enter)
+ let has_send_command_via_buffer = commands
+ .iter()
+ .any(|c| c.operation == "send_command_via_buffer");
+ assert!(!has_send_command_via_buffer);
+ }
+
+ #[test]
+ fn test_send_keys_safe_3kb_command_via_stdin() {
+ let client = MockTmuxClient::new();
+
+ client.create_session("test-session", "/tmp").unwrap();
+
+ // Create a 3.5KB command - exceeds both send-keys limit (~2KB) and typical CLI arg limits
+ // This verifies that the buffer method can handle content that would fail with CLI args
+ let long_content = "a".repeat(3500);
+ let long_cmd = format!("echo '{}'", long_content);
+ assert!(long_cmd.len() > 3500, "Command should be >3.5KB");
+
+ client
+ .send_keys_safe("test-session", &long_cmd, true)
+ .unwrap();
+
+ // Verify buffer was used (not direct send_keys)
+ let commands = client.get_commands();
+ assert!(
+ commands.iter().any(|c| c.operation == "set_buffer"),
+ "Should use set_buffer for 3KB+ commands"
+ );
+ assert!(
+ commands.iter().any(|c| c.operation == "paste_buffer"),
+ "Should paste buffer to session"
+ );
+
+ // Verify the full content was passed to set_buffer
+ let set_buffer_cmd = commands
+ .iter()
+ .find(|c| c.operation == "set_buffer")
+ .unwrap();
+ assert!(
+ set_buffer_cmd.args.len() >= 2,
+ "set_buffer should have buffer name and content"
+ );
+ assert!(
+ set_buffer_cmd.args[1].contains(&long_content),
+ "Full content should be passed to set_buffer"
+ );
+
+ // Buffer should be cleaned up after
+ let buffers = client.buffers.lock().unwrap();
+ assert!(buffers.is_empty(), "Buffer should be cleaned up after use");
+ }
+
+ #[test]
+ fn test_set_buffer_handles_special_characters() {
+ let client = MockTmuxClient::new();
+
+ // Test content with special shell characters that would need escaping with CLI args
+ let special_content =
+ r#"echo "hello $USER" && cat /etc/passwd | grep 'root' ; rm -rf /tmp/*"#;
+ client.set_buffer("special-buf", special_content).unwrap();
+
+ let buffers = client.buffers.lock().unwrap();
+ assert_eq!(
+ buffers.get("special-buf"),
+ Some(&special_content.to_string()),
+ "Special characters should be preserved exactly"
+ );
+ }
+
+ #[test]
+ fn test_set_buffer_handles_binary_like_content() {
+ let client = MockTmuxClient::new();
+
+ // Test content with null-like and other problematic characters
+ let binary_like = "hello\x00world\x01\x02\x03";
+ client.set_buffer("binary-buf", binary_like).unwrap();
+
+ let buffers = client.buffers.lock().unwrap();
+ assert_eq!(
+ buffers.get("binary-buf"),
+ Some(&binary_like.to_string()),
+ "Binary-like content should be handled"
+ );
+ }
}
diff --git a/src/app.rs b/src/app.rs
index e549bea..29b6ba9 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,11 +1,5 @@
use anyhow::Result;
-use crossterm::{
- event::{
- self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers,
- },
- execute,
- terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
-};
+use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::collections::HashMap;
use std::fs;
@@ -37,7 +31,7 @@ use crate::ui::setup::{DetectedToolInfo, SetupResult, SetupScreen};
use crate::ui::{
with_suspended_tui, CollectionSwitchDialog, ConfirmDialog, ConfirmSelection, Dashboard,
KanbanView, KanbanViewResult, SessionRecoveryDialog, SessionRecoverySelection,
- SyncConfirmDialog, SyncConfirmResult,
+ SyncConfirmDialog, SyncConfirmResult, TerminalGuard,
};
use std::sync::Arc;
@@ -299,10 +293,11 @@ impl App {
// Reconcile state with actual tmux sessions on startup
self.reconcile_sessions()?;
- // Setup terminal
- enable_raw_mode()?;
- let mut stdout = io::stdout();
- execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ // Terminal guard handles setup and cleanup on drop
+ // This ensures terminal is restored even on early returns via `?` or panics
+ let _terminal_guard = TerminalGuard::new()?;
+
+ let stdout = io::stdout();
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
@@ -440,14 +435,7 @@ impl App {
}
}
- // Restore terminal
- disable_raw_mode()?;
- execute!(
- terminal.backend_mut(),
- LeaveAlternateScreen,
- DisableMouseCapture
- )?;
- terminal.show_cursor()?;
+ // Terminal cleanup is handled by _terminal_guard drop
// Check for exit message (unimplemented features)
if let Some(message) = &self.exit_message {
diff --git a/src/backstage/runtime.rs b/src/backstage/runtime.rs
index 625e2c9..6536465 100644
--- a/src/backstage/runtime.rs
+++ b/src/backstage/runtime.rs
@@ -91,6 +91,7 @@ pub enum RuntimeError {
LocalFileNotFound(PathBuf),
#[error("Local file is not executable: {0}")]
+ #[allow(dead_code)] // Only used on Unix platforms
LocalFileNotExecutable(PathBuf),
#[error("IO error: {0}")]
diff --git a/src/git/cli.rs b/src/git/cli.rs
index bd6a39f..fa50c86 100644
--- a/src/git/cli.rs
+++ b/src/git/cli.rs
@@ -225,6 +225,29 @@ impl GitCli {
Self::run_git(&["rev-parse", "HEAD"], path).await
}
+ /// Check if a repository has at least one commit
+ #[instrument(skip_all, fields(path = %path.display()))]
+ pub async fn has_commits(path: &Path) -> Result {
+ match Self::run_git(&["rev-parse", "--verify", "HEAD"], path).await {
+ Ok(_) => Ok(true),
+ Err(e) => {
+ let err_msg = e.to_string();
+ // Various error messages indicate no commits exist:
+ // - "unknown revision" - older git versions
+ // - "ambiguous argument" - some git configurations
+ // - "Needed a single revision" - newer git versions with --verify
+ if err_msg.contains("unknown revision")
+ || err_msg.contains("ambiguous argument")
+ || err_msg.contains("Needed a single revision")
+ {
+ Ok(false)
+ } else {
+ Err(e)
+ }
+ }
+ }
+ }
+
/// Read a symbolic reference (like refs/remotes/origin/HEAD)
#[instrument(skip_all, fields(path = %path.display(), refname))]
pub async fn symbolic_ref(path: &Path, refname: &str) -> Result {
diff --git a/src/git/worktree.rs b/src/git/worktree.rs
index 11ad208..2897efb 100644
--- a/src/git/worktree.rs
+++ b/src/git/worktree.rs
@@ -108,6 +108,15 @@ impl WorktreeManager {
warn!("Failed to fetch from origin: {}", e);
}
+ // Verify repository has at least one commit
+ if !GitCli::has_commits(repo_path).await? {
+ return Err(anyhow!(
+ "Cannot create worktree: repository '{}' has no commits. \
+ Please make an initial commit first.",
+ repo_path.display()
+ ));
+ }
+
// Get the base commit for the target branch (callers pass full ref like "origin/main")
let base_ref = target_branch;
let base_commit = GitCli::head_commit(repo_path)
diff --git a/src/main.rs b/src/main.rs
index c82c6a7..4c6925f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -276,6 +276,10 @@ async fn main() -> Result<()> {
}
async fn run_tui(config: Config, log_file_path: Option, start_web: bool) -> Result<()> {
+ // Install panic hook before any terminal operations
+ // This ensures terminal is restored even on panic
+ crate::ui::install_panic_hook();
+
// Note: tmux availability is now checked in the setup wizard (TmuxOnboarding step)
// when the user selects tmux as their session wrapper
let mut app = App::new(config, start_web)?;
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 438e906..1da0e79 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -12,6 +12,7 @@ mod panels;
pub mod projects_dialog;
pub mod session_preview;
pub mod setup;
+pub mod terminal_guard;
pub mod terminal_suspend;
pub use collection_dialog::{CollectionInfo, CollectionSwitchDialog, CollectionSwitchResult};
@@ -25,4 +26,5 @@ pub use kanban_view::{KanbanView, KanbanViewResult};
pub use paginated_list::{render_paginated_list, PaginatedList};
pub use projects_dialog::ProjectsDialog;
pub use session_preview::SessionPreview;
+pub use terminal_guard::{install_panic_hook, TerminalGuard};
pub use terminal_suspend::with_suspended_tui;
diff --git a/src/ui/terminal_guard.rs b/src/ui/terminal_guard.rs
new file mode 100644
index 0000000..bb7fa2a
--- /dev/null
+++ b/src/ui/terminal_guard.rs
@@ -0,0 +1,101 @@
+//! Terminal state guard that ensures cleanup on drop.
+
+use anyhow::Result;
+use crossterm::{
+ cursor::Show,
+ event::{DisableMouseCapture, EnableMouseCapture},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use std::io::{self, Write};
+use std::sync::atomic::{AtomicBool, Ordering};
+
+/// RAII guard that restores terminal state on drop.
+///
+/// This ensures terminal cleanup happens even on:
+/// - Early returns via `?` operator
+/// - Panics (via panic hook)
+/// - Normal scope exit
+pub struct TerminalGuard {
+ active: AtomicBool,
+}
+
+impl TerminalGuard {
+ /// Initialize terminal for TUI mode and return guard.
+ ///
+ /// Enables raw mode, enters alternate screen, and enables mouse capture.
+ pub fn new() -> Result {
+ enable_raw_mode()?;
+ execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
+ Ok(Self {
+ active: AtomicBool::new(true),
+ })
+ }
+
+ /// Manually cleanup (used by panic hook).
+ pub fn cleanup() {
+ let _ = disable_raw_mode();
+ let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
+ let _ = execute!(io::stdout(), Show);
+ let _ = io::stdout().flush();
+ }
+}
+
+impl Drop for TerminalGuard {
+ fn drop(&mut self) {
+ if self.active.swap(false, Ordering::SeqCst) {
+ Self::cleanup();
+ }
+ }
+}
+
+/// Install panic hook that restores terminal before printing panic.
+pub fn install_panic_hook() {
+ let original_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |panic_info| {
+ // Restore terminal first so panic message is readable
+ TerminalGuard::cleanup();
+ // Then call original hook
+ original_hook(panic_info);
+ }));
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_terminal_guard_tracks_active_state() {
+ let guard = TerminalGuard {
+ active: AtomicBool::new(true),
+ };
+ assert!(guard.active.load(Ordering::SeqCst));
+ }
+
+ #[test]
+ fn test_terminal_guard_clears_active_on_drop() {
+ let guard = TerminalGuard {
+ active: AtomicBool::new(true),
+ };
+ assert!(guard.active.load(Ordering::SeqCst));
+ drop(guard);
+ // Guard dropped successfully without panic = success
+ }
+
+ #[test]
+ fn test_terminal_guard_no_double_cleanup() {
+ let guard = TerminalGuard {
+ active: AtomicBool::new(false),
+ };
+ // Should not attempt cleanup when active is already false
+ drop(guard);
+ // No panic = success
+ }
+
+ #[test]
+ fn test_cleanup_is_callable() {
+ // Just verify cleanup() doesn't panic when called
+ // (actual terminal ops will fail in test env but shouldn't panic)
+ TerminalGuard::cleanup();
+ }
+}
diff --git a/src/ui/terminal_suspend.rs b/src/ui/terminal_suspend.rs
index 5b94967..3006514 100644
--- a/src/ui/terminal_suspend.rs
+++ b/src/ui/terminal_suspend.rs
@@ -49,12 +49,27 @@ where
disable_raw_mode()?;
execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
+ // Guard ensures restore even on panic
+ struct RestoreGuard {
+ restored: bool,
+ }
+ impl Drop for RestoreGuard {
+ fn drop(&mut self) {
+ if !self.restored {
+ let _ = enable_raw_mode();
+ let _ = execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture);
+ }
+ }
+ }
+ let mut guard = RestoreGuard { restored: false };
+
// Run the closure
let result = f();
- // Restore TUI mode
+ // Explicit restore (mark guard as done)
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
+ guard.restored = true;
// Clear terminal to ensure proper redraw - this is critical!
// Without this, the TUI will have display artifacts from the external app.
@@ -114,4 +129,50 @@ mod tests {
// Clear should work
terminal.clear().unwrap();
}
+
+ #[test]
+ fn test_restore_guard_marks_as_restored() {
+ struct RestoreGuard {
+ restored: bool,
+ }
+ impl Drop for RestoreGuard {
+ fn drop(&mut self) {
+ // Would restore if not marked
+ }
+ }
+
+ let mut guard = RestoreGuard { restored: false };
+ guard.restored = true;
+ drop(guard);
+ // No panic = guard handled correctly
+ }
+
+ #[test]
+ fn test_restore_guard_catches_panic() {
+ use std::panic;
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::Arc;
+
+ struct RestoreGuard {
+ called: Arc,
+ }
+ impl Drop for RestoreGuard {
+ fn drop(&mut self) {
+ self.called.store(true, Ordering::SeqCst);
+ }
+ }
+
+ let called = Arc::new(AtomicBool::new(false));
+ let called_clone = called.clone();
+
+ let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
+ let _guard = RestoreGuard {
+ called: called_clone,
+ };
+ panic!("test panic");
+ }));
+
+ assert!(result.is_err());
+ assert!(called.load(Ordering::SeqCst));
+ }
}
diff --git a/tests/fixtures/long_echo_command.sh b/tests/fixtures/long_echo_command.sh
new file mode 100755
index 0000000..769091c
--- /dev/null
+++ b/tests/fixtures/long_echo_command.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# Test script for verifying tmux can handle long commands via send_keys_safe
+# This file is intentionally >3KB to exceed tmux send-keys and CLI arg limits
+#
+# Usage: This script can be sourced or executed to test that tmux can handle
+# commands that exceed ARG_MAX when passed via stdin piping.
+
+# Generate a 3.5KB+ echo statement
+# The actual content below is padding to exceed the 3KB threshold
+
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+echo "SUCCESS: Long echo command script completed"
diff --git a/tests/fixtures/test_long_command.sh b/tests/fixtures/test_long_command.sh
new file mode 100755
index 0000000..a2ee333
--- /dev/null
+++ b/tests/fixtures/test_long_command.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+# Manual test: Run this to verify long commands work with real tmux
+# This tests the stdin piping approach for set-buffer
+#
+# Usage: ./test_long_command.sh
+#
+# Expected output: Should show the echoed content and print SUCCESS
+
+set -e
+
+SESSION="op-test-long-cmd-$$"
+BUFFER="test-buf-$$"
+
+# Generate 4KB of content (exceeds typical CLI arg limits)
+CONTENT=$(printf 'echo "%s"' "$(head -c 4000 /dev/zero | tr '\0' 'a')")
+
+echo "=== Testing tmux set-buffer with stdin piping ==="
+echo "Content length: ${#CONTENT} bytes"
+echo "Session: $SESSION"
+echo "Buffer: $BUFFER"
+echo ""
+
+# Cleanup function
+cleanup() {
+ echo ""
+ echo "=== Cleanup ==="
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
+ tmux delete-buffer -b "$BUFFER" 2>/dev/null || true
+ echo "Done"
+}
+trap cleanup EXIT
+
+# Create session
+echo "Creating tmux session..."
+tmux new-session -d -s "$SESSION"
+
+# Test: load buffer via stdin (the new approach)
+# tmux load-buffer uses "-" to read from stdin, unlike set-buffer which takes CLI args
+echo "Loading buffer via stdin (4KB content)..."
+echo "$CONTENT" | tmux load-buffer -b "$BUFFER" -
+
+# Verify buffer was set
+echo "Verifying buffer content..."
+BUFFER_SIZE=$(tmux show-buffer -b "$BUFFER" | wc -c | tr -d ' ')
+echo "Buffer size: $BUFFER_SIZE bytes"
+
+if [ "$BUFFER_SIZE" -lt 4000 ]; then
+ echo "FAIL: Buffer content is too small (expected ~4000 bytes)"
+ exit 1
+fi
+
+# Paste buffer to session
+echo "Pasting buffer to session..."
+tmux paste-buffer -b "$BUFFER" -t "$SESSION"
+
+# Send Enter to execute
+echo "Sending Enter key..."
+tmux send-keys -t "$SESSION" Enter
+
+# Wait for execution
+sleep 1
+
+# Capture output
+echo ""
+echo "=== Captured pane output (last 5 lines) ==="
+tmux capture-pane -t "$SESSION" -p | tail -5
+
+echo ""
+echo "=== SUCCESS: Long command handling works correctly ==="
+echo "The stdin piping approach successfully bypasses CLI argument limits."
diff --git a/tests/git_integration.rs b/tests/git_integration.rs
index 8e9c4bf..1da5b2d 100644
--- a/tests/git_integration.rs
+++ b/tests/git_integration.rs
@@ -839,6 +839,80 @@ mod worktree_manager_tests {
// ─── WorktreeManager Error Tests ─────────────────────────────────────────────
+// ─── GitCli Empty Repo Tests ──────────────────────────────────────────────────
+
+mod git_cli_empty_repo_tests {
+ use super::*;
+
+ /// Test: has_commits returns false for empty repo
+ #[tokio::test]
+ async fn test_has_commits_empty_repo() {
+ skip_if_not_configured!();
+ let temp = TempDir::new().expect("Failed to create temp dir");
+
+ // Initialize empty git repo
+ std::process::Command::new("git")
+ .args(["init"])
+ .current_dir(temp.path())
+ .output()
+ .expect("Failed to init repo");
+
+ let has = GitCli::has_commits(temp.path())
+ .await
+ .expect("Should check commits");
+
+ assert!(!has, "Empty repo should have no commits");
+ }
+
+ /// Test: has_commits returns true for repo with commits
+ #[tokio::test]
+ async fn test_has_commits_with_commit() {
+ skip_if_not_configured!();
+ let repo_path = get_repo_path();
+
+ let has = GitCli::has_commits(&repo_path)
+ .await
+ .expect("Should check commits");
+
+ assert!(has, "Operator repo should have commits");
+ }
+
+ /// Test: create_for_ticket fails gracefully on empty repo
+ #[tokio::test]
+ async fn test_create_worktree_empty_repo_fails() {
+ skip_if_not_configured!();
+ let temp_worktrees = TempDir::new().expect("temp dir");
+ let temp_repo = TempDir::new().expect("temp repo");
+
+ // Initialize empty git repo
+ std::process::Command::new("git")
+ .args(["init"])
+ .current_dir(temp_repo.path())
+ .output()
+ .expect("Failed to init repo");
+
+ let manager = WorktreeManager::new(temp_worktrees.path().to_path_buf());
+
+ let result = manager
+ .create_for_ticket(
+ temp_repo.path(),
+ "test-project",
+ "FEAT-001",
+ "test-branch",
+ "main",
+ )
+ .await;
+
+ assert!(result.is_err(), "Should fail on empty repo");
+ let err = result.unwrap_err().to_string();
+ assert!(
+ err.contains("no commits"),
+ "Error should mention 'no commits': {}",
+ err
+ );
+ }
+}
+
mod worktree_manager_error_tests {
use super::*;
diff --git a/tests/launch_integration.rs b/tests/launch_integration.rs
index 1d7aff8..e536c2e 100644
--- a/tests/launch_integration.rs
+++ b/tests/launch_integration.rs
@@ -399,6 +399,25 @@ config_generated = false
contents
}
+ /// Read command file content from the commands directory
+ fn read_command_files(&self) -> Vec<(PathBuf, String)> {
+ let commands_dir = self.tickets_path.join("operator/commands");
+ let mut files = Vec::new();
+
+ if let Ok(entries) = fs::read_dir(commands_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().map(|e| e == "sh").unwrap_or(false) {
+ if let Ok(content) = fs::read_to_string(&path) {
+ files.push((path, content));
+ }
+ }
+ }
+ }
+
+ files
+ }
+
/// Check if ticket was moved to in-progress
fn ticket_in_progress(&self) -> bool {
let in_progress = self.tickets_path.join("in-progress");
@@ -774,6 +793,76 @@ status: queued
}
}
+#[test]
+fn test_command_file_is_created_during_launch() {
+ skip_if_not_configured!();
+
+ let ctx = LaunchTestContext::new("command_file");
+
+ let ticket_content = r#"---
+id: TASK-008
+priority: P2-medium
+status: queued
+---
+
+# Task: Test command file creation
+
+## Context
+This test verifies that a command shell script is created during launch.
+"#;
+ ctx.create_ticket("TASK", "TASK-008", ticket_content);
+
+ ctx.run_launch(&[]);
+ std::thread::sleep(Duration::from_secs(2));
+
+ // Check command files in the commands directory
+ let command_files = ctx.read_command_files();
+ assert!(
+ !command_files.is_empty(),
+ "Should have at least one command file"
+ );
+
+ // Verify command file structure
+ let (path, content) = &command_files[0];
+ eprintln!("Command file path: {:?}", path);
+ eprintln!("Command file content:\n{}", content);
+
+ // Should have shebang
+ assert!(
+ content.starts_with("#!/bin/bash"),
+ "Command file should start with shebang. Got: {}",
+ &content[..std::cmp::min(50, content.len())]
+ );
+
+ // Should have cd command
+ assert!(
+ content.contains("cd "),
+ "Command file should contain cd command. Got: {}",
+ content
+ );
+
+ // Should have exec command with LLM tool
+ assert!(
+ content.contains("exec "),
+ "Command file should contain exec command. Got: {}",
+ content
+ );
+
+ // Should be executable on Unix
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let metadata = fs::metadata(path).unwrap();
+ let permissions = metadata.permissions();
+ let mode = permissions.mode();
+ assert!(
+ mode & 0o111 != 0,
+ "Command file should be executable. Mode: {:o}",
+ mode
+ );
+ }
+}
+
mod error_handling {
use super::*;
diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore
index 18db329..af738d7 100644
--- a/vscode-extension/.vscodeignore
+++ b/vscode-extension/.vscodeignore
@@ -9,3 +9,4 @@ tsconfig.json
**/*.map
node_modules/**
*.vsix
+coverage/
\ No newline at end of file
diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md
index dd4d8bb..565a6a6 100644
--- a/vscode-extension/CHANGELOG.md
+++ b/vscode-extension/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-All notable changes to the Operator Terminals extension will be documented in this file.
+All notable changes to **Operator! Terminals** VS Code extension will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts
index b0b7978..008d4fe 100644
--- a/vscode-extension/src/extension.ts
+++ b/vscode-extension/src/extension.ts
@@ -215,7 +215,8 @@ async function findParentTicketsDir(): Promise {
}
/**
- * Find the .tickets directory in the workspace (for webhook session file)
+ * Find the .tickets directory for webhook session file.
+ * Walks up from workspace to find existing .tickets, or creates in parent (org level).
*/
async function findTicketsDir(): Promise {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
@@ -223,27 +224,75 @@ async function findTicketsDir(): Promise {
return undefined;
}
- // Check configured tickets directory
const configuredDir = vscode.workspace
.getConfiguration('operator')
.get('ticketsDir', '.tickets');
- const ticketsPath = path.isAbsolute(configuredDir)
- ? configuredDir
- : path.join(workspaceFolder.uri.fsPath, configuredDir);
-
- try {
- await fs.access(ticketsPath);
- return ticketsPath;
- } catch {
- // .tickets directory doesn't exist yet - create it
+ // If absolute path configured, use it directly
+ if (path.isAbsolute(configuredDir)) {
try {
- await fs.mkdir(ticketsPath, { recursive: true });
- return ticketsPath;
+ await fs.mkdir(configuredDir, { recursive: true });
+ return configuredDir;
} catch {
return undefined;
}
}
+
+ // Walk up from workspace to find existing .tickets directory
+ let currentDir = workspaceFolder.uri.fsPath;
+ const root = path.parse(currentDir).root;
+
+ while (currentDir !== root) {
+ const ticketsPath = path.join(currentDir, configuredDir);
+ try {
+ await fs.access(ticketsPath);
+ return ticketsPath; // Found existing .tickets
+ } catch {
+ // Not found, try parent
+ currentDir = path.dirname(currentDir);
+ }
+ }
+
+ // Not found - create in parent of workspace (org level)
+ const parentDir = path.dirname(workspaceFolder.uri.fsPath);
+ if (parentDir === workspaceFolder.uri.fsPath) {
+ // Workspace is at filesystem root
+ return undefined;
+ }
+
+ const ticketsPath = path.join(parentDir, configuredDir);
+ try {
+ await fs.mkdir(ticketsPath, { recursive: true });
+ return ticketsPath;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Find the directory to run the operator server in.
+ * Prefers parent directory if it has .tickets/operator/, otherwise uses workspace.
+ */
+async function findOperatorServerDir(): Promise {
+ const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
+ if (!workspaceFolder) {
+ return undefined;
+ }
+
+ const workspaceDir = workspaceFolder.uri.fsPath;
+ const parentDir = path.dirname(workspaceDir);
+
+ // Check if parent has .tickets/operator/ (initialized operator setup)
+ const parentOperatorPath = path.join(parentDir, '.tickets', 'operator');
+ try {
+ await fs.access(parentOperatorPath);
+ return parentDir; // Parent has initialized operator
+ } catch {
+ // Parent doesn't have .tickets/operator
+ }
+
+ // Fall back to workspace directory
+ return workspaceDir;
}
/**
@@ -715,17 +764,13 @@ async function startOperatorServerCommand(): Promise {
return;
}
- // Find the parent directory containing .tickets
- if (!currentTicketsDir) {
- vscode.window.showErrorMessage(
- 'No .tickets directory found. Operator requires a .tickets directory.'
- );
+ // Find the directory to run the operator server in
+ const serverDir = await findOperatorServerDir();
+ if (!serverDir) {
+ vscode.window.showErrorMessage('No workspace folder found.');
return;
}
- // Get parent of .tickets (the project root)
- const projectRoot = path.dirname(currentTicketsDir);
-
// Check if Operator is already running
const apiClient = new OperatorApiClient();
try {
@@ -746,14 +791,14 @@ async function startOperatorServerCommand(): Promise {
await terminalManager.create({
name: terminalName,
- workingDir: projectRoot,
+ workingDir: serverDir,
});
await terminalManager.send(terminalName, `"${operatorPath}" api`);
await terminalManager.focus(terminalName);
vscode.window.showInformationMessage(
- `Starting Operator API server in ${projectRoot}...`
+ `Starting Operator API server in ${serverDir}...`
);
// Wait a moment and refresh providers to pick up the new status
diff --git a/zed-extension/Cargo.lock b/zed-extension/Cargo.lock
new file mode 100644
index 0000000..fd3533e
--- /dev/null
+++ b/zed-extension/Cargo.lock
@@ -0,0 +1,825 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
+name = "auditable-serde"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7bf8143dfc3c0258df908843e169b5cc5fcf76c7718bd66135ef4a9cd558c5"
+dependencies = [
+ "semver",
+ "serde",
+ "serde_json",
+ "topological-sort",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "flate2"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "icu_collections"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+
+[[package]]
+name = "icu_properties"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+
+[[package]]
+name = "icu_provider"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "id-arena"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "litemap"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "operator-zed"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "zed_extension_api",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "spdx"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "topological-sort"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "wasm-encoder"
+version = "0.227.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.227.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1ef0faabbbba6674e97a56bee857ccddf942785a336c8b47b42373c922a91d"
+dependencies = [
+ "anyhow",
+ "auditable-serde",
+ "flate2",
+ "indexmap",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "spdx",
+ "url",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.227.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10fb6648689b3929d56bbc7eb1acf70c9a42a29eb5358c67c10f54dbd5d695de"
+dependencies = [
+ "wit-bindgen-rt",
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92fa781d4f2ff6d3f27f3cc9b74a73327b31ca0dc4a3ef25a0ce2983e0e5af9b"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db52a11d4dfb0a59f194c064055794ee6564eb1ced88c25da2cf76e50c5621"
+dependencies = [
+ "bitflags",
+ "futures",
+ "once_cell",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d0809dc5ba19e2e98661bf32fc0addc5a3ca5bf3a6a7083aa6ba484085ff3ce"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad19eec017904e04c60719592a803ee5da76cb51c81e3f6fbf9457f59db49799"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.227.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "635c3adc595422cbf2341a17fb73a319669cc8d33deed3a48368a841df86b676"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.227.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddf445ed5157046e4baf56f9138c124a0824d4d1657e7204d71886ad8ce2fc11"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+
+[[package]]
+name = "yoke"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zed_extension_api"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0729d50b4ca0a7e28e590bbe32e3ca0194d97ef654961451a424c661a366fca0"
+dependencies = [
+ "serde",
+ "serde_json",
+ "wit-bindgen",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
diff --git a/zed-extension/Cargo.toml b/zed-extension/Cargo.toml
new file mode 100644
index 0000000..533b8ce
--- /dev/null
+++ b/zed-extension/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "operator-zed"
+version = "0.1.0"
+edition = "2021"
+description = "Zed extension for Operator multi-agent orchestration"
+license = "MIT"
+authors = ["Samuel Volin "]
+repository = "https://github.com/untra/operator"
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+zed_extension_api = "0.7.0"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
diff --git a/zed-extension/LICENSE b/zed-extension/LICENSE
new file mode 100644
index 0000000..ed31a92
--- /dev/null
+++ b/zed-extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Samuel Volin @ untra
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/zed-extension/README.md b/zed-extension/README.md
new file mode 100644
index 0000000..1927477
--- /dev/null
+++ b/zed-extension/README.md
@@ -0,0 +1,178 @@
+# Operator Zed Extension
+
+Zed extension providing slash commands for interacting with [Operator](https://operator.untra.io), a multi-agent orchestration system for Claude Code.
+
+## Features
+
+This extension adds 11 slash commands to Zed's AI assistant for managing Operator:
+
+| Command | Description |
+|---------|-------------|
+| `/op-status` | Show Operator health and status |
+| `/op-queue` | List tickets in queue |
+| `/op-launch TICKET-ID` | Launch a ticket |
+| `/op-active` | List active agents |
+| `/op-completed` | List recently completed tickets |
+| `/op-ticket TICKET-ID` | Show ticket details |
+| `/op-pause` | Pause queue processing |
+| `/op-resume` | Resume queue processing |
+| `/op-sync` | Sync kanban collections |
+| `/op-approve AGENT-ID` | Approve agent review |
+| `/op-reject AGENT-ID REASON` | Reject agent review |
+
+## Prerequisites
+
+- [Operator](https://operator.untra.io) installed and running (`operator api`)
+- Rust toolchain with `wasm32-wasip1` target
+- Zed editor
+
+### Installing Rust WASM Target
+
+```bash
+rustup target add wasm32-wasip1
+```
+
+## Building
+
+```bash
+cd zed-extension
+cargo build --release --target wasm32-wasip1
+```
+
+The compiled extension will be at `target/wasm32-wasip1/release/operator_zed.wasm`.
+
+## Installation (Development)
+
+1. Build the extension:
+ ```bash
+ cargo build --release --target wasm32-wasip1
+ ```
+
+2. Create a dev extension directory in Zed's extensions folder:
+ ```bash
+ mkdir -p ~/.local/share/zed/extensions/installed/operator-dev
+ ```
+
+3. Copy the extension files:
+ ```bash
+ cp extension.toml ~/.local/share/zed/extensions/installed/operator-dev/
+ cp target/wasm32-wasip1/release/operator_zed.wasm ~/.local/share/zed/extensions/installed/operator-dev/extension.wasm
+ ```
+
+4. Restart Zed or use **Extensions: Reload** command
+
+## Usage
+
+1. Start the Operator API server:
+ ```bash
+ operator api
+ ```
+
+2. Open Zed's AI assistant panel (Cmd+Shift+A or Ctrl+Shift+A)
+
+3. Type a slash command to interact with Operator:
+ ```
+ /op-status
+ ```
+
+4. Commands with arguments support autocompletion:
+ ```
+ /op-launch FIX- # Tab to autocomplete ticket IDs
+ ```
+
+### Example Workflow
+
+```
+User: /op-status
+Assistant: [Shows Operator status with queue count, active agents, etc.]
+
+User: /op-queue
+Assistant: [Lists tickets in queue with ID, project, type, title]
+
+User: /op-launch FIX-123
+Assistant: [Launches the ticket and shows the command to run]
+
+User: /op-active
+Assistant: [Shows running agents with their status]
+```
+
+## Architecture
+
+### WASM Sandbox Limitations
+
+Zed extensions run in a WebAssembly sandbox with limited capabilities:
+
+- **No native HTTP**: We use `curl` subprocess calls to communicate with the Operator REST API
+- **No sidebar views**: UI is limited to slash command output in the AI assistant
+- **No status bar**: Cannot show persistent status indicators
+- **No webhooks**: Cannot receive callbacks from Operator
+
+### Communication Flow
+
+```
+Zed AI Assistant
+ │
+ ├──[slash command]──▶ Extension (WASM)
+ │ │
+ │ ├──[subprocess]──▶ curl
+ │ │ │
+ │ │ ▼
+ │ │ Operator API
+ │ │ (localhost:7008)
+ │ │ │
+ │ ◀──[JSON response]───┘
+ │ │
+ ◀──[markdown output]──────┘
+```
+
+## Alternative: Tasks
+
+For actions that require a terminal, you can configure Zed tasks in `.zed/tasks.json`:
+
+```json
+[
+ {
+ "label": "Operator: Start API",
+ "command": "operator api",
+ "use_new_terminal": true
+ },
+ {
+ "label": "Operator: Show Queue",
+ "command": "operator queue",
+ "use_new_terminal": false
+ },
+ {
+ "label": "Operator: Launch Next",
+ "command": "operator launch --next",
+ "use_new_terminal": true
+ }
+]
+```
+
+## Configuration
+
+The extension connects to `http://localhost:7008` by default. This matches Operator's default API port.
+
+To use a different API URL, you would need to modify the `DEFAULT_API_URL` constant in `src/lib.rs` and rebuild.
+
+## Troubleshooting
+
+### "Failed to execute curl"
+
+Ensure `curl` is available in your PATH. On most systems it's pre-installed.
+
+### "API request failed"
+
+1. Check that Operator is running: `operator api`
+2. Verify the API is accessible: `curl http://localhost:7008/api/v1/health`
+3. Check Operator logs for errors
+
+### Extension not appearing
+
+1. Verify the extension files are in the correct location
+2. Check Zed's extension logs: **View > Output > Extensions**
+3. Try reloading extensions or restarting Zed
+
+## License
+
+MIT License - see [LICENSE](LICENSE)
diff --git a/zed-extension/TODO.md b/zed-extension/TODO.md
new file mode 100644
index 0000000..60f4338
--- /dev/null
+++ b/zed-extension/TODO.md
@@ -0,0 +1,139 @@
+# Zed Extension TODO
+
+Feature comparison and implementation status vs VS Code extension.
+
+## VS Code Extension Commands → Zed Slash Commands
+
+| VS Code Command | Zed Equivalent | Status |
+|-----------------|----------------|--------|
+| `operator.showStatus` | `/op-status` | ✅ Implemented |
+| `operator.refreshTickets` | N/A | ❌ No UI to refresh |
+| `operator.focusTicket` | N/A | ❌ No terminal API |
+| `operator.openTicket` | `/op-ticket` | ✅ Shows details (can't open file) |
+| `operator.launchTicket` | `/op-launch` | ✅ Implemented |
+| `operator.launchTicketWithOptions` | N/A | ❌ No dialog API |
+| `operator.relaunchTicket` | `/op-launch` | ✅ Can relaunch same ticket |
+| `operator.launchTicketFromEditor` | N/A | ❌ No editor context |
+| `operator.downloadOperator` | N/A | ❌ Use manual install |
+| `operator.pauseQueue` | `/op-pause` | ✅ Implemented |
+| `operator.resumeQueue` | `/op-resume` | ✅ Implemented |
+| `operator.syncKanban` | `/op-sync` | ✅ Implemented |
+| `operator.approveReview` | `/op-approve` | ✅ Implemented |
+| `operator.rejectReview` | `/op-reject` | ✅ Implemented |
+| `operator.startOperatorServer` | N/A | ❌ Use Tasks or terminal |
+
+## VS Code Features → Zed Status
+
+| Feature | VS Code | Zed | Notes |
+|---------|---------|-----|-------|
+| **Sidebar Views** | ✅ 4 TreeViews | ❌ N/A | Zed has no sidebar extension API |
+| **Status Bar** | ✅ Live indicator | ❌ N/A | Zed has no status bar API |
+| **Webhook Server** | ✅ Port 7009 | ❌ N/A | WASM sandbox prevents servers |
+| **Terminal Management** | ✅ Create/style/track | ❌ N/A | Zed has no terminal extension API |
+| **File Watching** | ✅ .tickets/ watcher | ❌ N/A | No file watcher in extensions |
+| **REST Client** | ✅ Native fetch | ✅ curl subprocess | Works but slower |
+| **Ticket Completion** | ✅ QuickPick | ✅ Argument completion | Works for ticket IDs |
+| **Launch Options Dialog** | ✅ Multi-select | ❌ N/A | No dialog API |
+| **Color-coded Terminals** | ✅ By issue type | ❌ N/A | Use Zed Tasks instead |
+
+## Implemented Slash Commands
+
+- [x] `/op-status` - Show Operator health/status
+- [x] `/op-queue` - List tickets in queue
+- [x] `/op-launch TICKET-ID` - Launch a ticket
+- [x] `/op-active` - List active agents
+- [x] `/op-completed` - List completed tickets
+- [x] `/op-ticket TICKET-ID` - Show ticket details
+- [x] `/op-pause` - Pause queue processing
+- [x] `/op-resume` - Resume queue processing
+- [x] `/op-sync` - Sync kanban collections
+- [x] `/op-approve AGENT-ID` - Approve review
+- [x] `/op-reject AGENT-ID REASON` - Reject review
+
+## Not Possible in Zed
+
+These features cannot be implemented due to Zed's extension API limitations:
+
+1. **Sidebar Views**
+ - No TreeDataProvider equivalent
+ - Status, queue, active, completed views all unavailable
+ - Workaround: Use slash commands to query data
+
+2. **Webhook Server**
+ - WASM sandbox prevents opening ports
+ - Cannot receive notifications from Operator
+ - Workaround: Poll with slash commands
+
+3. **Terminal Management**
+ - Cannot create/manage terminals programmatically
+ - Cannot set terminal colors or icons
+ - Workaround: Use Zed Tasks (`.zed/tasks.json`)
+
+4. **Status Bar**
+ - No API to add status bar items
+ - Cannot show persistent status indicator
+ - Workaround: Use `/op-status` command
+
+5. **File System Watching**
+ - Cannot watch for ticket file changes
+ - Auto-refresh not possible
+ - Workaround: Manual refresh via commands
+
+6. **Editor Context Commands**
+ - Cannot detect active editor file
+ - Cannot launch ticket from open file
+ - Workaround: Use `/op-launch` with explicit ID
+
+## Future Improvements
+
+When Zed's extension API expands:
+
+- [ ] Add sidebar views if TreeDataProvider added
+- [ ] Add status bar item if API becomes available
+- [ ] Add terminal creation if API becomes available
+- [ ] Add file watching for auto-refresh
+- [ ] Add configuration UI when settings API available
+
+## Alternative Workflows
+
+### Using Zed Tasks
+
+For terminal-based workflows, create `.zed/tasks.json`:
+
+```json
+[
+ {
+ "label": "Operator: Start API Server",
+ "command": "operator api",
+ "use_new_terminal": true,
+ "allow_concurrent_runs": false
+ },
+ {
+ "label": "Operator: Show Queue (CLI)",
+ "command": "operator queue",
+ "use_new_terminal": false
+ },
+ {
+ "label": "Operator: Launch Next Ticket",
+ "command": "operator launch --next",
+ "use_new_terminal": true
+ },
+ {
+ "label": "Operator: Show Active Agents",
+ "command": "operator agents",
+ "use_new_terminal": false
+ }
+]
+```
+
+### Using the AI Assistant
+
+The slash commands are designed to work well in the AI assistant context:
+
+1. Ask about status: `/op-status`
+2. See what needs work: `/op-queue`
+3. Get details: `/op-ticket FIX-123`
+4. Launch work: `/op-launch FIX-123`
+5. Monitor progress: `/op-active`
+
+The AI assistant can use this information contextually to help with your work.
diff --git a/zed-extension/extension.toml b/zed-extension/extension.toml
new file mode 100644
index 0000000..62a05ed
--- /dev/null
+++ b/zed-extension/extension.toml
@@ -0,0 +1,23 @@
+[extension]
+id = "operator"
+name = "Operator"
+description = "Integration with Operator multi-agent orchestration for Claude Code"
+version = "0.1.0"
+schema_version = 1
+authors = ["Samuel Volin "]
+repository = "https://github.com/untra/operator"
+
+[language_servers]
+
+[slash_commands]
+op-status = { description = "Show Operator health and status" }
+op-queue = { description = "List tickets in queue" }
+op-launch = { description = "Launch a ticket by ID", requires_argument = true }
+op-active = { description = "List active agents" }
+op-completed = { description = "List recently completed tickets" }
+op-ticket = { description = "Show ticket details by ID", requires_argument = true }
+op-pause = { description = "Pause queue processing" }
+op-resume = { description = "Resume queue processing" }
+op-sync = { description = "Sync kanban collections" }
+op-approve = { description = "Approve agent review by agent ID", requires_argument = true }
+op-reject = { description = "Reject agent review with reason", requires_argument = true }
diff --git a/zed-extension/src/lib.rs b/zed-extension/src/lib.rs
new file mode 100644
index 0000000..6427f96
--- /dev/null
+++ b/zed-extension/src/lib.rs
@@ -0,0 +1,615 @@
+//! Zed Extension for Operator
+//!
+//! Provides slash commands for interacting with the Operator multi-agent
+//! orchestration system from Zed's AI assistant.
+//!
+//! Since Zed extensions run in a WASM sandbox, we communicate with the
+//! Operator REST API via curl subprocess calls.
+
+use serde::Deserialize;
+use std::process::Command;
+use zed_extension_api::{
+ self as zed, SlashCommand, SlashCommandArgumentCompletion, SlashCommandOutput,
+ SlashCommandOutputSection,
+};
+
+/// Default Operator API URL
+const DEFAULT_API_URL: &str = "http://localhost:7008";
+
+/// Operator Zed Extension
+struct OperatorExtension {
+ api_url: String,
+}
+
+impl OperatorExtension {
+ fn new() -> Self {
+ Self {
+ api_url: DEFAULT_API_URL.to_string(),
+ }
+ }
+
+ /// Execute a curl command and return the output
+ fn curl_get(&self, endpoint: &str) -> Result {
+ let url = format!("{}{}", self.api_url, endpoint);
+ let output = Command::new("curl")
+ .args(["-s", "-f", &url])
+ .output()
+ .map_err(|e| format!("Failed to execute curl: {}", e))?;
+
+ if output.status.success() {
+ String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 response: {}", e))
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(format!("API request failed: {}", stderr))
+ }
+ }
+
+ /// Execute a curl POST command
+ fn curl_post(&self, endpoint: &str, body: Option<&str>) -> Result {
+ let url = format!("{}{}", self.api_url, endpoint);
+ let mut cmd = Command::new("curl");
+ cmd.args(["-s", "-f", "-X", "POST"]);
+
+ if let Some(json_body) = body {
+ cmd.args(["-H", "Content-Type: application/json", "-d", json_body]);
+ }
+
+ cmd.arg(&url);
+
+ let output = cmd
+ .output()
+ .map_err(|e| format!("Failed to execute curl: {}", e))?;
+
+ if output.status.success() {
+ String::from_utf8(output.stdout).map_err(|e| format!("Invalid UTF-8 response: {}", e))
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(format!("API request failed: {}", stderr))
+ }
+ }
+
+ /// Handle /op-status command
+ fn handle_status(&self) -> SlashCommandOutput {
+ match self.curl_get("/api/v1/health") {
+ Ok(json) => {
+ if let Ok(health) = serde_json::from_str::(&json) {
+ let text = format!(
+ "## Operator Status\n\n\
+ **Status**: {}\n\
+ **Version**: {}\n\
+ **Uptime**: {} seconds\n\
+ **Queue Processing**: {}\n\n\
+ | Metric | Count |\n\
+ |--------|-------|\n\
+ | Queue | {} |\n\
+ | Active Agents | {} |\n\
+ | Completed Today | {} |",
+ health.status,
+ health.version,
+ health.uptime_seconds,
+ if health.queue_paused {
+ "paused"
+ } else {
+ "running"
+ },
+ health.queue_count,
+ health.active_agents,
+ health.completed_today
+ );
+ make_output(&text, "Operator Status")
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Operator Status (raw)")
+ }
+ }
+ Err(e) => make_error(&format!(
+ "Failed to get Operator status.\n\n\
+ **Error**: {}\n\n\
+ Make sure Operator is running: `operator api`",
+ e
+ )),
+ }
+ }
+
+ /// Handle /op-queue command
+ fn handle_queue(&self) -> SlashCommandOutput {
+ match self.curl_get("/api/v1/tickets/queue") {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ if response.tickets.is_empty() {
+ make_output("## Queue\n\n*No tickets in queue*", "Queue")
+ } else {
+ let mut text =
+ "## Queue\n\n| ID | Project | Type | Title |\n|---|---|---|---|\n"
+ .to_string();
+ for ticket in &response.tickets {
+ text.push_str(&format!(
+ "| {} | {} | {} | {} |\n",
+ ticket.id,
+ ticket.project.as_deref().unwrap_or("-"),
+ ticket.issue_type.as_deref().unwrap_or("-"),
+ ticket.title.as_deref().unwrap_or("-")
+ ));
+ }
+ text.push_str(&format!(
+ "\n*{} ticket(s) in queue*",
+ response.tickets.len()
+ ));
+ make_output(&text, "Queue")
+ }
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Queue (raw)")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to fetch queue: {}", e)),
+ }
+ }
+
+ /// Handle /op-launch command
+ fn handle_launch(&self, ticket_id: &str) -> SlashCommandOutput {
+ let body = r#"{"provider":null,"wrapper":"terminal","model":"sonnet","yolo_mode":false,"retry_reason":null,"resume_session_id":null}"#;
+ match self.curl_post(&format!("/api/v1/tickets/{}/launch", ticket_id), Some(body)) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ let worktree_msg = if response.worktree_created {
+ " (worktree created)"
+ } else {
+ ""
+ };
+ let text = format!(
+ "## Launched: {}{}\n\n\
+ **Working Directory**: `{}`\n\
+ **Terminal**: {}\n\n\
+ Run this command in your terminal:\n\
+ ```bash\n{}\n```",
+ response.ticket_id,
+ worktree_msg,
+ response.working_directory,
+ response.terminal_name,
+ response.command
+ );
+ make_output(&text, &format!("Launched {}", response.ticket_id))
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Launch Response")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to launch ticket {}: {}", ticket_id, e)),
+ }
+ }
+
+ /// Handle /op-active command
+ fn handle_active(&self) -> SlashCommandOutput {
+ match self.curl_get("/api/v1/agents/active") {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ if response.agents.is_empty() {
+ make_output("## Active Agents\n\n*No active agents*", "Active Agents")
+ } else {
+ let mut text =
+ "## Active Agents\n\n| ID | Ticket | Project | Status |\n|---|---|---|---|\n"
+ .to_string();
+ for agent in &response.agents {
+ text.push_str(&format!(
+ "| {} | {} | {} | {} |\n",
+ &agent.id[..8.min(agent.id.len())],
+ agent.ticket_id,
+ agent.project,
+ agent.status
+ ));
+ }
+ text.push_str(&format!("\n*{} active agent(s)*", response.agents.len()));
+ make_output(&text, "Active Agents")
+ }
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Active Agents (raw)")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to fetch active agents: {}", e)),
+ }
+ }
+
+ /// Handle /op-completed command
+ fn handle_completed(&self) -> SlashCommandOutput {
+ match self.curl_get("/api/v1/tickets/completed") {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ if response.tickets.is_empty() {
+ make_output(
+ "## Completed Tickets\n\n*No recently completed tickets*",
+ "Completed",
+ )
+ } else {
+ let mut text =
+ "## Completed Tickets\n\n| ID | Project | Type | Title |\n|---|---|---|---|\n"
+ .to_string();
+ for ticket in response.tickets.iter().take(10) {
+ text.push_str(&format!(
+ "| {} | {} | {} | {} |\n",
+ ticket.id,
+ ticket.project.as_deref().unwrap_or("-"),
+ ticket.issue_type.as_deref().unwrap_or("-"),
+ ticket.title.as_deref().unwrap_or("-")
+ ));
+ }
+ text.push_str(&format!(
+ "\n*Showing {} of {} completed ticket(s)*",
+ 10.min(response.tickets.len()),
+ response.tickets.len()
+ ));
+ make_output(&text, "Completed")
+ }
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Completed (raw)")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to fetch completed tickets: {}", e)),
+ }
+ }
+
+ /// Handle /op-ticket command
+ fn handle_ticket(&self, ticket_id: &str) -> SlashCommandOutput {
+ match self.curl_get(&format!("/api/v1/tickets/{}", ticket_id)) {
+ Ok(json) => {
+ if let Ok(ticket) = serde_json::from_str::(&json) {
+ let text = format!(
+ "## Ticket: {}\n\n\
+ **Title**: {}\n\
+ **Type**: {}\n\
+ **Project**: {}\n\
+ **Status**: {}\n\
+ **Priority**: {}\n\n\
+ ### Description\n\n{}",
+ ticket.id,
+ ticket.title.as_deref().unwrap_or("-"),
+ ticket.issue_type.as_deref().unwrap_or("-"),
+ ticket.project.as_deref().unwrap_or("-"),
+ ticket.status.as_deref().unwrap_or("-"),
+ ticket.priority.unwrap_or(0),
+ ticket.description.as_deref().unwrap_or("*No description*")
+ );
+ make_output(&text, &format!("Ticket {}", ticket.id))
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Ticket (raw)")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to fetch ticket {}: {}", ticket_id, e)),
+ }
+ }
+
+ /// Handle /op-pause command
+ fn handle_pause(&self) -> SlashCommandOutput {
+ match self.curl_post("/api/v1/queue/pause", None) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ make_output(
+ &format!("## Queue Paused\n\n{}", response.message),
+ "Queue Paused",
+ )
+ } else {
+ make_output(
+ "## Queue Paused\n\nQueue processing has been paused.",
+ "Queue Paused",
+ )
+ }
+ }
+ Err(e) => make_error(&format!("Failed to pause queue: {}", e)),
+ }
+ }
+
+ /// Handle /op-resume command
+ fn handle_resume(&self) -> SlashCommandOutput {
+ match self.curl_post("/api/v1/queue/resume", None) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ make_output(
+ &format!("## Queue Resumed\n\n{}", response.message),
+ "Queue Resumed",
+ )
+ } else {
+ make_output(
+ "## Queue Resumed\n\nQueue processing has been resumed.",
+ "Queue Resumed",
+ )
+ }
+ }
+ Err(e) => make_error(&format!("Failed to resume queue: {}", e)),
+ }
+ }
+
+ /// Handle /op-sync command
+ fn handle_sync(&self) -> SlashCommandOutput {
+ match self.curl_post("/api/v1/kanban/sync", None) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ let text = format!(
+ "## Kanban Sync Complete\n\n\
+ **Created**: {}\n\
+ **Skipped**: {}\n\
+ **Errors**: {}",
+ response.created.len(),
+ response.skipped.len(),
+ response.errors.len()
+ );
+ make_output(&text, "Kanban Sync")
+ } else {
+ make_output(&format!("```json\n{}\n```", json), "Kanban Sync (raw)")
+ }
+ }
+ Err(e) => make_error(&format!("Failed to sync kanban: {}", e)),
+ }
+ }
+
+ /// Handle /op-approve command
+ fn handle_approve(&self, agent_id: &str) -> SlashCommandOutput {
+ match self.curl_post(&format!("/api/v1/agents/{}/approve", agent_id), None) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ make_output(
+ &format!("## Review Approved\n\n{}", response.message),
+ "Review Approved",
+ )
+ } else {
+ make_output(
+ &format!("## Review Approved\n\nAgent {} approved.", agent_id),
+ "Review Approved",
+ )
+ }
+ }
+ Err(e) => make_error(&format!("Failed to approve agent {}: {}", agent_id, e)),
+ }
+ }
+
+ /// Handle /op-reject command
+ fn handle_reject(&self, args: &str) -> SlashCommandOutput {
+ // Parse: AGENT-ID REASON
+ let parts: Vec<&str> = args.splitn(2, ' ').collect();
+ if parts.len() < 2 {
+ return make_error(
+ "Usage: /op-reject AGENT-ID REASON\n\nPlease provide both agent ID and rejection reason.",
+ );
+ }
+
+ let agent_id = parts[0];
+ let reason = parts[1];
+
+ let body = format!(r#"{{"reason":"{}"}}"#, reason.replace('"', "\\\""));
+ match self.curl_post(&format!("/api/v1/agents/{}/reject", agent_id), Some(&body)) {
+ Ok(json) => {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ make_output(
+ &format!("## Review Rejected\n\n{}", response.message),
+ "Review Rejected",
+ )
+ } else {
+ make_output(
+ &format!(
+ "## Review Rejected\n\nAgent {} rejected.\n\n**Reason**: {}",
+ agent_id, reason
+ ),
+ "Review Rejected",
+ )
+ }
+ }
+ Err(e) => make_error(&format!("Failed to reject agent {}: {}", agent_id, e)),
+ }
+ }
+
+ /// Get ticket IDs for completion
+ fn get_queue_ticket_ids(&self) -> Vec {
+ if let Ok(json) = self.curl_get("/api/v1/tickets/queue") {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ return response.tickets.into_iter().map(|t| t.id).collect();
+ }
+ }
+ Vec::new()
+ }
+
+ /// Get agent IDs awaiting input for completion
+ fn get_awaiting_agent_ids(&self) -> Vec<(String, String)> {
+ if let Ok(json) = self.curl_get("/api/v1/agents/active") {
+ if let Ok(response) = serde_json::from_str::(&json) {
+ return response
+ .agents
+ .into_iter()
+ .filter(|a| a.status == "awaiting_input")
+ .map(|a| (a.id, a.ticket_id))
+ .collect();
+ }
+ }
+ Vec::new()
+ }
+}
+
+impl zed::Extension for OperatorExtension {
+ fn new() -> Self
+ where
+ Self: Sized,
+ {
+ OperatorExtension::new()
+ }
+
+ fn run_slash_command(
+ &self,
+ command: SlashCommand,
+ args: Vec,
+ _worktree: Option<&zed::Worktree>,
+ ) -> Result {
+ let arg = args.join(" ");
+
+ match command.name.as_str() {
+ "op-status" => Ok(self.handle_status()),
+ "op-queue" => Ok(self.handle_queue()),
+ "op-launch" => {
+ if arg.is_empty() {
+ Ok(make_error("Usage: /op-launch TICKET-ID"))
+ } else {
+ Ok(self.handle_launch(&arg))
+ }
+ }
+ "op-active" => Ok(self.handle_active()),
+ "op-completed" => Ok(self.handle_completed()),
+ "op-ticket" => {
+ if arg.is_empty() {
+ Ok(make_error("Usage: /op-ticket TICKET-ID"))
+ } else {
+ Ok(self.handle_ticket(&arg))
+ }
+ }
+ "op-pause" => Ok(self.handle_pause()),
+ "op-resume" => Ok(self.handle_resume()),
+ "op-sync" => Ok(self.handle_sync()),
+ "op-approve" => {
+ if arg.is_empty() {
+ Ok(make_error("Usage: /op-approve AGENT-ID"))
+ } else {
+ Ok(self.handle_approve(&arg))
+ }
+ }
+ "op-reject" => {
+ if arg.is_empty() {
+ Ok(make_error("Usage: /op-reject AGENT-ID REASON"))
+ } else {
+ Ok(self.handle_reject(&arg))
+ }
+ }
+ _ => Err(format!("Unknown command: {}", command.name)),
+ }
+ }
+
+ fn complete_slash_command_argument(
+ &self,
+ command: SlashCommand,
+ _args: Vec,
+ ) -> Result, String> {
+ match command.name.as_str() {
+ "op-launch" | "op-ticket" => {
+ let ticket_ids = self.get_queue_ticket_ids();
+ Ok(ticket_ids
+ .into_iter()
+ .map(|id| SlashCommandArgumentCompletion {
+ label: id.clone(),
+ new_text: id,
+ run_command: true,
+ })
+ .collect())
+ }
+ "op-approve" => {
+ let agents = self.get_awaiting_agent_ids();
+ Ok(agents
+ .into_iter()
+ .map(|(id, ticket_id)| SlashCommandArgumentCompletion {
+ label: format!("{} ({})", &id[..8.min(id.len())], ticket_id),
+ new_text: id,
+ run_command: true,
+ })
+ .collect())
+ }
+ "op-reject" => {
+ let agents = self.get_awaiting_agent_ids();
+ Ok(agents
+ .into_iter()
+ .map(|(id, ticket_id)| SlashCommandArgumentCompletion {
+ label: format!("{} ({})", &id[..8.min(id.len())], ticket_id),
+ new_text: format!("{} ", id), // Space for reason
+ run_command: false, // Don't run yet, need reason
+ })
+ .collect())
+ }
+ _ => Ok(Vec::new()),
+ }
+ }
+}
+
+// Helper function to create output
+fn make_output(text: &str, label: &str) -> SlashCommandOutput {
+ SlashCommandOutput {
+ text: text.to_string(),
+ sections: vec![SlashCommandOutputSection {
+ range: (0..text.len()).into(),
+ label: label.to_string(),
+ }],
+ }
+}
+
+// Helper function to create error output
+fn make_error(message: &str) -> SlashCommandOutput {
+ let text = format!("## Error\n\n{}", message);
+ SlashCommandOutput {
+ text: text.clone(),
+ sections: vec![SlashCommandOutputSection {
+ range: (0..text.len()).into(),
+ label: "Error".to_string(),
+ }],
+ }
+}
+
+// API Response types
+#[derive(Deserialize)]
+struct HealthResponse {
+ status: String,
+ version: String,
+ uptime_seconds: u64,
+ queue_paused: bool,
+ queue_count: usize,
+ active_agents: usize,
+ completed_today: usize,
+}
+
+#[derive(Deserialize)]
+struct TicketsResponse {
+ tickets: Vec,
+}
+
+#[derive(Deserialize)]
+struct TicketSummary {
+ id: String,
+ title: Option,
+ project: Option,
+ issue_type: Option,
+}
+
+#[derive(Deserialize)]
+struct TicketDetail {
+ id: String,
+ title: Option,
+ project: Option,
+ issue_type: Option,
+ status: Option,
+ priority: Option,
+ description: Option,
+}
+
+#[derive(Deserialize)]
+struct AgentsResponse {
+ agents: Vec,
+}
+
+#[derive(Deserialize)]
+struct AgentSummary {
+ id: String,
+ ticket_id: String,
+ project: String,
+ status: String,
+}
+
+#[derive(Deserialize)]
+struct LaunchResponse {
+ ticket_id: String,
+ terminal_name: String,
+ working_directory: String,
+ command: String,
+ worktree_created: bool,
+}
+
+#[derive(Deserialize)]
+struct MessageResponse {
+ message: String,
+}
+
+#[derive(Deserialize)]
+struct SyncResponse {
+ created: Vec,
+ skipped: Vec,
+ errors: Vec,
+}
+
+zed::register_extension!(OperatorExtension);