Based on claude-code-tools TmuxCLIController, this refactor: - Added DockerTmuxController class for robust tmux session management - Implements send_keys() with configurable delay_enter - Implements capture_pane() for output retrieval - Implements wait_for_prompt() for pattern-based completion detection - Implements wait_for_idle() for content-hash-based idle detection - Implements wait_for_shell_prompt() for shell prompt detection Also includes workflow improvements: - Pre-task git snapshot before agent execution - Post-task commit protocol in agent guidelines Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
247 lines
7.8 KiB
Python
Executable File
247 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Luz Server Orchestrator
|
|
|
|
Single-process orchestrator that routes requests to project-specific subagents.
|
|
Replaces multiple Claude sessions with one efficient coordinator.
|
|
|
|
Usage:
|
|
# Interactive mode
|
|
python orchestrator.py
|
|
|
|
# Single task
|
|
python orchestrator.py -p "Check overbits build status"
|
|
|
|
# Specific project
|
|
python orchestrator.py --project overbits -p "Run tests"
|
|
"""
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
|
|
CONFIG_PATH = Path(__file__).parent / "config.json"
|
|
LOG_DIR = Path("/var/log/claude-orchestrator")
|
|
|
|
@dataclass
|
|
class ProjectConfig:
|
|
path: str
|
|
description: str
|
|
subagent_model: str
|
|
tools: list
|
|
focus: str
|
|
|
|
class Orchestrator:
|
|
def __init__(self):
|
|
self.config = self._load_config()
|
|
self.projects: Dict[str, ProjectConfig] = {}
|
|
self._parse_projects()
|
|
|
|
def _load_config(self) -> dict:
|
|
"""Load orchestrator configuration"""
|
|
if CONFIG_PATH.exists():
|
|
with open(CONFIG_PATH) as f:
|
|
return json.load(f)
|
|
return {"projects": {}}
|
|
|
|
def _parse_projects(self):
|
|
"""Parse project configurations"""
|
|
for name, cfg in self.config.get("projects", {}).items():
|
|
self.projects[name] = ProjectConfig(
|
|
path=cfg.get("path", f"/home/{name}"),
|
|
description=cfg.get("description", ""),
|
|
subagent_model=cfg.get("subagent_model", "haiku"),
|
|
tools=cfg.get("tools", ["Read", "Glob", "Grep"]),
|
|
focus=cfg.get("focus", "")
|
|
)
|
|
|
|
def detect_project(self, prompt: str) -> Optional[str]:
|
|
"""Detect which project a prompt relates to"""
|
|
prompt_lower = prompt.lower()
|
|
|
|
# Direct mentions
|
|
for name in self.projects:
|
|
if name in prompt_lower:
|
|
return name
|
|
|
|
# Path mentions
|
|
for name, cfg in self.projects.items():
|
|
if cfg.path in prompt:
|
|
return name
|
|
|
|
# Keyword matching
|
|
keywords = {
|
|
"admin": ["server", "nginx", "systemd", "user", "mcp"],
|
|
"overbits": ["frontend", "react", "typescript", "vite"],
|
|
"musica": ["music", "strudel", "pattern", "audio"],
|
|
"dss": ["signature", "crypto", "certificate"],
|
|
"librechat": ["chat", "librechat", "conversation"],
|
|
"bbot": ["trading", "bot", "market"]
|
|
}
|
|
|
|
for name, kws in keywords.items():
|
|
if name in self.projects:
|
|
for kw in kws:
|
|
if kw in prompt_lower:
|
|
return name
|
|
|
|
return None
|
|
|
|
def run_subagent(self, project: str, prompt: str,
|
|
tools: Optional[list] = None,
|
|
model: Optional[str] = None) -> dict:
|
|
"""Run a subagent for a specific project"""
|
|
cfg = self.projects.get(project)
|
|
if not cfg:
|
|
return {"error": f"Unknown project: {project}"}
|
|
|
|
# Use config defaults or overrides
|
|
agent_tools = tools or cfg.tools
|
|
agent_model = model or cfg.subagent_model
|
|
|
|
# Build the prompt with project context
|
|
full_prompt = f"""You are a subagent for the {project} project.
|
|
|
|
Working directory: {cfg.path}
|
|
Focus: {cfg.focus}
|
|
Description: {cfg.description}
|
|
|
|
Task: {prompt}
|
|
|
|
Execute this task efficiently and return a concise summary."""
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"claude",
|
|
"-p", full_prompt,
|
|
"--output-format", "json",
|
|
"--allowedTools", ",".join(agent_tools),
|
|
"--model", agent_model
|
|
],
|
|
cwd=cfg.path,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300
|
|
)
|
|
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
return {
|
|
"result": result.stdout,
|
|
"stderr": result.stderr,
|
|
"returncode": result.returncode
|
|
}
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"error": "Task timed out after 5 minutes"}
|
|
except Exception as e:
|
|
return {"error": str(e)}
|
|
|
|
def route_request(self, prompt: str) -> dict:
|
|
"""Route a request to the appropriate subagent"""
|
|
project = self.detect_project(prompt)
|
|
|
|
if project:
|
|
print(f"[Orchestrator] Routing to {project} subagent...")
|
|
return self.run_subagent(project, prompt)
|
|
else:
|
|
# Multi-project or general request
|
|
print("[Orchestrator] No specific project detected, running general task...")
|
|
return self._run_general(prompt)
|
|
|
|
def _run_general(self, prompt: str) -> dict:
|
|
"""Run a general task not specific to any project"""
|
|
result = subprocess.run(
|
|
[
|
|
"claude",
|
|
"-p", prompt,
|
|
"--output-format", "json",
|
|
"--allowedTools", "Read,Glob,Grep,Bash"
|
|
],
|
|
cwd="/home/admin",
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=300
|
|
)
|
|
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except:
|
|
return {"result": result.stdout}
|
|
|
|
def health_check_all(self) -> dict:
|
|
"""Run health checks across all projects"""
|
|
results = {}
|
|
for name in self.projects:
|
|
print(f"[Health Check] {name}...")
|
|
results[name] = self.run_subagent(
|
|
name,
|
|
"Quick health check: verify project status, check for errors",
|
|
tools=["Read", "Glob", "Bash"]
|
|
)
|
|
return results
|
|
|
|
def list_projects(self) -> None:
|
|
"""List all configured projects"""
|
|
print("\n=== Configured Projects ===\n")
|
|
for name, cfg in self.projects.items():
|
|
print(f" {name}:")
|
|
print(f" Path: {cfg.path}")
|
|
print(f" Model: {cfg.subagent_model}")
|
|
print(f" Focus: {cfg.focus}")
|
|
print()
|
|
|
|
def main():
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description="Luz Server Orchestrator")
|
|
parser.add_argument("-p", "--prompt", help="Task prompt to execute")
|
|
parser.add_argument("--project", help="Specific project to target")
|
|
parser.add_argument("--list", action="store_true", help="List projects")
|
|
parser.add_argument("--health", action="store_true", help="Health check all")
|
|
|
|
args = parser.parse_args()
|
|
|
|
orch = Orchestrator()
|
|
|
|
if args.list:
|
|
orch.list_projects()
|
|
elif args.health:
|
|
results = orch.health_check_all()
|
|
print(json.dumps(results, indent=2))
|
|
elif args.prompt:
|
|
if args.project:
|
|
result = orch.run_subagent(args.project, args.prompt)
|
|
else:
|
|
result = orch.route_request(args.prompt)
|
|
print(json.dumps(result, indent=2))
|
|
else:
|
|
# Interactive mode
|
|
print("Luz Orchestrator - Type 'quit' to exit, 'list' for projects")
|
|
while True:
|
|
try:
|
|
prompt = input("\n> ").strip()
|
|
if prompt.lower() == 'quit':
|
|
break
|
|
elif prompt.lower() == 'list':
|
|
orch.list_projects()
|
|
elif prompt.lower() == 'health':
|
|
results = orch.health_check_all()
|
|
print(json.dumps(results, indent=2))
|
|
elif prompt:
|
|
result = orch.route_request(prompt)
|
|
print(json.dumps(result, indent=2))
|
|
except KeyboardInterrupt:
|
|
print("\nExiting...")
|
|
break
|
|
|
|
if __name__ == "__main__":
|
|
main()
|