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>
241 lines
8.0 KiB
Python
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
|