Files
jpmschweitzerandClaude Opus 4.7 a355751437 move python clide to legacy/
Clide is being rebuilt as a Flutter desktop app. The Python Textual
implementation moves wholesale into legacy/ rather than being deleted:
its pane model, panel set, git skills, and panel communication design
are real thought that should remain readable next to the new code
while the rebuild finds its shape. Git's rename tracking preserves
history, so `git log -- legacy/` still works.

The Flutter rebuild lives at the repo root alongside a Go sidecar
(the architecture claudian was heading toward, which folds into
clide as a core component rather than a separate plugin project).
Bootstrap of the new shape lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 20:30:51 +02:00

241 lines
8.0 KiB
Python

"""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