feat: add clide-web package replacing ttyd + zellij
Python web server that wraps Clide for browser access. Replaces the previous ttyd (C binary) + zellij (Rust binary) stack with a single FastAPI application using tmux for session persistence. Key features: - WebSocket ↔ PTY bridge via tmux attach - Project switching via /projects/<name> URL routing - Vendored xterm.js for offline LAN operation - Auto-respawn on Clide exit (tmux pane-died hook) - Setup wizard for first-run configuration - No scrollbar (TUI manages its own scrolling) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
"""tmux session manager: create, attach, list, kill sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from clide.models.db import Project, Session
|
||||
from sqlmodel import Session as DBSession
|
||||
from sqlmodel import select
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from clide_web.config import ClideWebSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TmuxSessionManager:
|
||||
"""Manages tmux sessions, one per project."""
|
||||
|
||||
def __init__(self, settings: ClideWebSettings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Project discovery
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_projects(self) -> list[str]:
|
||||
"""List git repos in the projects directory."""
|
||||
projects_dir = self._settings.projects_dir
|
||||
if not projects_dir.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in projects_dir.iterdir() if d.is_dir() and (d / ".git").exists()
|
||||
)
|
||||
|
||||
def validate_project(self, name: str) -> Path | None:
|
||||
"""Return project path if valid, else None."""
|
||||
project_dir = self._settings.projects_dir / name
|
||||
if project_dir.is_dir() and (project_dir / ".git").exists():
|
||||
return project_dir
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# tmux operations
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_name(project: str) -> str:
|
||||
return f"clide-{project}"
|
||||
|
||||
async def session_exists(self, project: str) -> bool:
|
||||
"""Check if a tmux session exists for this project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"has-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
return proc.returncode == 0
|
||||
|
||||
async def create_session(self, project: str, db: DBSession) -> str:
|
||||
"""Create a new tmux session running clide for the given project.
|
||||
|
||||
Returns the tmux session name.
|
||||
"""
|
||||
name = self._session_name(project)
|
||||
project_dir = self.validate_project(project)
|
||||
if project_dir is None:
|
||||
raise ValueError(f"Project '{project}' not found")
|
||||
|
||||
# Check if session already exists
|
||||
if await self.session_exists(project):
|
||||
logger.info("tmux session %s already exists, reusing", name)
|
||||
else:
|
||||
clide_bin = _resolve_clide_bin(self._settings.clide_bin)
|
||||
env = _build_env(self._settings.term)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"new-session",
|
||||
"-d", # detached
|
||||
"-s",
|
||||
name, # session name
|
||||
"-c",
|
||||
str(project_dir), # working directory
|
||||
"-x",
|
||||
str(self._settings.default_cols),
|
||||
"-y",
|
||||
str(self._settings.default_rows),
|
||||
clide_bin, # command to run
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create tmux session: {stderr.decode().strip()}")
|
||||
|
||||
# Configure session: hide status bar, auto-respawn on exit
|
||||
# pane-died hook: kill the dead pane, clear all history, respawn clean
|
||||
respawn_cmd = (
|
||||
f"respawn-pane -k -t {name} -c {project_dir} {clide_bin} \\; "
|
||||
f"clear-history -t {name}"
|
||||
)
|
||||
for opt_args in [
|
||||
["set-option", "-t", name, "status", "off"],
|
||||
["set-option", "-t", name, "remain-on-exit", "on"],
|
||||
["set-hook", "-t", name, "pane-died", respawn_cmd],
|
||||
]:
|
||||
await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
*opt_args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
logger.info("Created tmux session %s for project %s", name, project)
|
||||
|
||||
# Persist to DB
|
||||
_upsert_project(db, project, str(project_dir))
|
||||
_upsert_session(db, project, name)
|
||||
|
||||
return name
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, str]]:
|
||||
"""List active tmux sessions matching clide-* pattern."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}:#{session_created}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
|
||||
sessions = []
|
||||
for line in stdout.decode().strip().splitlines():
|
||||
if not line.startswith("clide-"):
|
||||
continue
|
||||
parts = line.split(":", 1)
|
||||
name = parts[0]
|
||||
project = name.removeprefix("clide-")
|
||||
sessions.append({"name": name, "project": project})
|
||||
return sessions
|
||||
|
||||
async def kill_session(self, project: str) -> None:
|
||||
"""Kill a tmux session for a project."""
|
||||
name = self._session_name(project)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"tmux",
|
||||
"kill-session",
|
||||
"-t",
|
||||
name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
await proc.wait()
|
||||
logger.info("Killed tmux session %s", name)
|
||||
|
||||
async def cleanup_dead_sessions(self, db: DBSession) -> None:
|
||||
"""Sync DB session records with actual tmux state."""
|
||||
live = await self.list_sessions()
|
||||
live_names = {s["name"] for s in live}
|
||||
|
||||
stmt = select(Session).where(Session.status == "active")
|
||||
for session in db.exec(stmt).all():
|
||||
if session.tmux_session not in live_names:
|
||||
session.status = "dead"
|
||||
db.add(session)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_env(term: str) -> dict[str, str]:
|
||||
"""Build environment for tmux sessions, inheriting the current env."""
|
||||
import os
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TERM"] = term
|
||||
env["COLORTERM"] = "truecolor"
|
||||
return env
|
||||
|
||||
|
||||
def _resolve_clide_bin(clide_bin: str) -> str:
|
||||
"""Resolve clide binary path to absolute if relative."""
|
||||
import shutil
|
||||
|
||||
path = Path(clide_bin)
|
||||
if path.is_absolute():
|
||||
return clide_bin
|
||||
# Relative path — resolve against cwd
|
||||
resolved = Path.cwd() / path
|
||||
if resolved.is_file():
|
||||
return str(resolved)
|
||||
# Try to find it on PATH
|
||||
found = shutil.which(clide_bin)
|
||||
if found:
|
||||
return found
|
||||
return clide_bin # Last resort: return as-is
|
||||
|
||||
|
||||
def _upsert_project(db: DBSession, name: str, path: str) -> Project:
|
||||
"""Create or update a project record."""
|
||||
stmt = select(Project).where(Project.name == name)
|
||||
project = db.exec(stmt).first()
|
||||
if project is None:
|
||||
project = Project(name=name, path=path)
|
||||
project.last_accessed = datetime.utcnow()
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
return project
|
||||
|
||||
|
||||
def _upsert_session(db: DBSession, project_name: str, tmux_session: str) -> Session:
|
||||
"""Create or update a session record."""
|
||||
stmt = select(Session).where(Session.tmux_session == tmux_session)
|
||||
session = db.exec(stmt).first()
|
||||
if session is None:
|
||||
session = Session(project_name=project_name, tmux_session=tmux_session)
|
||||
session.status = "active"
|
||||
session.last_activity = datetime.utcnow()
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
return session
|
||||
Reference in New Issue
Block a user