This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
gsd-openclaw: GSD to OpenClaw bridge skill.
|
||||
|
||||
This package provides the bridge between OpenClaw and GSD (Get Shit Done),
|
||||
enabling users to interact with GSD through messaging platforms.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from gsd_openclaw.state_manager import SessionState, StateManager, save_state, load_state
|
||||
from gsd_openclaw.workspace import Workspace, WorkspaceManager
|
||||
from gsd_openclaw.tmux_session import TmuxSession, TmuxSessionManager, SessionInfo
|
||||
|
||||
__all__ = [
|
||||
"SessionState",
|
||||
"StateManager",
|
||||
"save_state",
|
||||
"load_state",
|
||||
"Workspace",
|
||||
"WorkspaceManager",
|
||||
"TmuxSession",
|
||||
"TmuxSessionManager",
|
||||
"SessionInfo",
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI for gsd-openclaw session management."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import List
|
||||
|
||||
from gsd_openclaw.tmux_session import (
|
||||
TmuxSessionManager,
|
||||
TmuxSessionError,
|
||||
CleanupResult,
|
||||
)
|
||||
|
||||
|
||||
def cmd_create(args):
|
||||
"""Create a new session for a project."""
|
||||
manager = TmuxSessionManager()
|
||||
try:
|
||||
session = manager.create_session(
|
||||
project_name=args.project,
|
||||
display_name=args.display_name
|
||||
)
|
||||
print(f"Created session: {session.tmux_session_name}")
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print(f"Project: {session.project_name}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
"""List all gsd sessions."""
|
||||
manager = TmuxSessionManager()
|
||||
sessions = manager.list_gsd_sessions()
|
||||
|
||||
if not sessions:
|
||||
print("No active sessions.")
|
||||
return
|
||||
|
||||
print(f"{'Project':<20} {'Session ID':<10} {'Last Active':<20} {'Status'}")
|
||||
print("-" * 70)
|
||||
for s in sessions:
|
||||
status = "ORPHAN" if s.is_orphan else "active"
|
||||
display = s.display_name or s.project_name
|
||||
print(f"{display:<20} {s.session_id:<10} {s.last_active_at:<20} {status}")
|
||||
|
||||
|
||||
def cmd_destroy(args):
|
||||
"""Destroy a session."""
|
||||
manager = TmuxSessionManager()
|
||||
result = manager.destroy_session(
|
||||
project_name=args.project,
|
||||
cleanup_workspace=args.cleanup
|
||||
)
|
||||
if result:
|
||||
print(f"Destroyed session for: {args.project}")
|
||||
else:
|
||||
print(f"No session found for: {args.project}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_resume(args):
|
||||
"""Resume an existing session."""
|
||||
manager = TmuxSessionManager()
|
||||
try:
|
||||
session = manager.resume_session(project_name=args.project)
|
||||
print(f"Resumed session: {session.tmux_session_name}")
|
||||
print(f"Session ID: {session.session_id}")
|
||||
print(f"Project: {session.project_name}")
|
||||
except TmuxSessionError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_cleanup(args):
|
||||
"""Clean up orphan sessions."""
|
||||
manager = TmuxSessionManager()
|
||||
result = manager.cleanup_orphans()
|
||||
|
||||
if result.cleaned_sessions:
|
||||
print(f"Cleaned {len(result.cleaned_sessions)} orphan session(s):")
|
||||
for name in result.cleaned_sessions:
|
||||
print(f" - {name}")
|
||||
|
||||
if result.errors:
|
||||
print(f"Errors ({len(result.errors)}):")
|
||||
for err in result.errors:
|
||||
print(f" - {err}")
|
||||
|
||||
if not result.cleaned_sessions and not result.errors:
|
||||
print("No orphan sessions found.")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gsd-ocl",
|
||||
description="GSD-OpenClaw session management"
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# create
|
||||
p_create = subparsers.add_parser("create", help="Create a new session")
|
||||
p_create.add_argument("project", help="Project name")
|
||||
p_create.add_argument("--display-name", "-d", help="Friendly display name")
|
||||
p_create.set_defaults(func=cmd_create)
|
||||
|
||||
# list
|
||||
p_list = subparsers.add_parser("list", help="List all sessions")
|
||||
p_list.set_defaults(func=cmd_list)
|
||||
|
||||
# destroy
|
||||
p_destroy = subparsers.add_parser("destroy", help="Destroy a session")
|
||||
p_destroy.add_argument("project", help="Project name")
|
||||
p_destroy.add_argument("--cleanup", "-c", action="store_true",
|
||||
help="Also remove workspace directory")
|
||||
p_destroy.set_defaults(func=cmd_destroy)
|
||||
|
||||
# resume
|
||||
p_resume = subparsers.add_parser("resume", help="Resume a session")
|
||||
p_resume.add_argument("project", help="Project name")
|
||||
p_resume.set_defaults(func=cmd_resume)
|
||||
|
||||
# cleanup
|
||||
p_cleanup = subparsers.add_parser("cleanup", help="Clean up orphan sessions")
|
||||
p_cleanup.set_defaults(func=cmd_cleanup)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
State persistence layer for gsd-openclaw.
|
||||
|
||||
Provides atomic state saves with backup rotation for crash-safe session checkpointing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
"""Represents the persistent state of a GSD session.
|
||||
|
||||
Attributes:
|
||||
schema_version: Version of the state schema (default "1.0")
|
||||
session_id: Unique identifier for this session
|
||||
project_name: Name of the project
|
||||
tmux_session_name: Name of the tmux session
|
||||
tmux_pane_id: ID of the tmux pane
|
||||
created_at: ISO format timestamp when session was created
|
||||
last_active_at: ISO format timestamp of last activity
|
||||
command_count: Number of commands executed in this session
|
||||
last_output_snapshot: Last captured output from the session
|
||||
display_name: User-friendly display name for the session
|
||||
"""
|
||||
schema_version: str = "1.0"
|
||||
session_id: str = ""
|
||||
project_name: str = ""
|
||||
tmux_session_name: str = ""
|
||||
tmux_pane_id: str = ""
|
||||
created_at: str = ""
|
||||
last_active_at: str = ""
|
||||
command_count: int = 0
|
||||
last_output_snapshot: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert SessionState to a dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the session state.
|
||||
"""
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> SessionState:
|
||||
"""Create SessionState from a dictionary.
|
||||
|
||||
Unknown fields in the dictionary are ignored.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing session state data.
|
||||
|
||||
Returns:
|
||||
SessionState instance.
|
||||
"""
|
||||
# Get field names from the dataclass
|
||||
field_names = {f.name for f in dataclasses.fields(cls)}
|
||||
# Filter to only known fields
|
||||
filtered_data = {k: v for k, v in data.items() if k in field_names}
|
||||
return cls(**filtered_data)
|
||||
|
||||
|
||||
# Placeholder functions - to be implemented in Task 2 and 3
|
||||
def save_state(state: SessionState, state_path: Path) -> None:
|
||||
"""Save session state to file atomically with backup rotation.
|
||||
|
||||
This function implements crash-safe state persistence:
|
||||
1. Creates parent directories if they don't exist
|
||||
2. Rotates existing state file to backup (state.json -> state.json.bak)
|
||||
3. Writes to a temporary file first
|
||||
4. Atomically renames temp file to final path
|
||||
|
||||
The atomic rename ensures that:
|
||||
- Readers never see a partially written file
|
||||
- Crash during write leaves either old or new file intact
|
||||
|
||||
Args:
|
||||
state: The SessionState to save.
|
||||
state_path: Path where state.json should be written.
|
||||
|
||||
Raises:
|
||||
OSError: If file operations fail (permissions, disk full, etc.)
|
||||
"""
|
||||
state_path = Path(state_path)
|
||||
backup_path = state_path.with_suffix('.json.bak')
|
||||
|
||||
# 1. Create parent directories if missing
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 2. Rotate backup: if state.json exists, move it to state.json.bak
|
||||
if state_path.exists():
|
||||
# Remove old backup if exists
|
||||
if backup_path.exists():
|
||||
backup_path.unlink()
|
||||
# Rename current state to backup
|
||||
state_path.rename(backup_path)
|
||||
|
||||
# 3. Write to temp file in same directory (for atomic rename)
|
||||
fd, temp_path = tempfile.mkstemp(dir=state_path.parent, suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
json.dump(state.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
# 4. Atomic replace
|
||||
os.replace(temp_path, state_path)
|
||||
except Exception:
|
||||
# Clean up temp file on failure
|
||||
if os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
raise
|
||||
|
||||
|
||||
def load_state(state_path: Path) -> Optional[SessionState]:
|
||||
"""Load session state from file with validation and backup fallback.
|
||||
|
||||
This function implements robust state loading:
|
||||
1. If state.json doesn't exist, try state.json.bak
|
||||
2. If neither exists, return None
|
||||
3. Read and parse JSON with utf-8 encoding
|
||||
4. Validate schema version (must be "1.0")
|
||||
5. Validate required fields (session_id, project_name, tmux_session_name)
|
||||
6. On JSONDecodeError or ValueError, fall back to backup
|
||||
|
||||
Args:
|
||||
state_path: Path to state.json file.
|
||||
|
||||
Returns:
|
||||
SessionState if loaded successfully, None if no state file exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If schema version is unsupported or required fields are missing
|
||||
(only raised if backup also fails).
|
||||
"""
|
||||
state_path = Path(state_path)
|
||||
backup_path = state_path.with_suffix('.json.bak')
|
||||
|
||||
def _validate_and_load(path: Path) -> SessionState:
|
||||
"""Internal: Load and validate state from a specific path."""
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Validate schema version
|
||||
schema_version = data.get('schema_version', '')
|
||||
if schema_version != '1.0':
|
||||
raise ValueError(f"Unsupported schema version: {schema_version}")
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['session_id', 'project_name', 'tmux_session_name']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
raise ValueError(f"Missing required field: {field}")
|
||||
|
||||
return SessionState.from_dict(data)
|
||||
|
||||
# Try main file first, then backup
|
||||
paths_to_try = []
|
||||
if state_path.exists():
|
||||
paths_to_try.append(state_path)
|
||||
if backup_path.exists() and backup_path != state_path:
|
||||
paths_to_try.append(backup_path)
|
||||
|
||||
if not paths_to_try:
|
||||
return None
|
||||
|
||||
last_error = None
|
||||
for path in paths_to_try:
|
||||
try:
|
||||
return _validate_and_load(path)
|
||||
except json.JSONDecodeError as e:
|
||||
last_error = e
|
||||
continue # Try next path (backup)
|
||||
except ValueError as e:
|
||||
last_error = e
|
||||
continue # Try next path (backup)
|
||||
|
||||
# If we get here, all paths failed
|
||||
if last_error:
|
||||
raise last_error
|
||||
return None
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manager class for state persistence operations.
|
||||
|
||||
Provides a clean interface for state persistence without requiring
|
||||
callers to manage paths directly. Encapsulates workspace path resolution
|
||||
and delegates to save_state/load_state functions.
|
||||
|
||||
Attributes:
|
||||
WORKSPACES_ROOT: Default root directory for all workspaces.
|
||||
project_name: Name of the project.
|
||||
workspaces_root: Root directory for workspaces (configurable for testing).
|
||||
workspace_path: Full path to the project's workspace.
|
||||
state_path: Full path to the project's state.json file.
|
||||
"""
|
||||
|
||||
WORKSPACES_ROOT = Path.home() / "gsd-workspaces"
|
||||
|
||||
def __init__(self, project_name: str, workspaces_root: Optional[Path] = None):
|
||||
"""Initialize StateManager for a project.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project (used for workspace directory).
|
||||
workspaces_root: Optional custom root for workspaces (useful for testing).
|
||||
Defaults to ~/gsd-workspaces.
|
||||
"""
|
||||
self.project_name = project_name
|
||||
self.workspaces_root = Path(workspaces_root) if workspaces_root else self.WORKSPACES_ROOT
|
||||
self.workspace_path = self.workspaces_root / project_name
|
||||
self.state_path = self.workspace_path / ".gsd" / "state.json"
|
||||
|
||||
def save(self, state: SessionState) -> None:
|
||||
"""Save session state to the project's state file.
|
||||
|
||||
Args:
|
||||
state: The SessionState to save.
|
||||
|
||||
Raises:
|
||||
OSError: If file operations fail.
|
||||
"""
|
||||
save_state(state, self.state_path)
|
||||
|
||||
def load(self) -> Optional[SessionState]:
|
||||
"""Load session state from the project's state file.
|
||||
|
||||
Returns:
|
||||
SessionState if loaded successfully, None if no state file exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If schema version is unsupported or required fields are missing.
|
||||
"""
|
||||
return load_state(self.state_path)
|
||||
|
||||
def get_state_path(self) -> Path:
|
||||
"""Get the path to the project's state file.
|
||||
|
||||
Returns:
|
||||
Path to state.json file.
|
||||
"""
|
||||
return self.state_path
|
||||
@@ -0,0 +1,467 @@
|
||||
"""Tmux session lifecycle management using libtmux.
|
||||
|
||||
This module provides the core functionality for creating, listing, and destroying
|
||||
tmux sessions. It is the primary interface to tmux that all other components use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
import libtmux
|
||||
import libtmux.exc
|
||||
|
||||
from gsd_openclaw.state_manager import StateManager, SessionState
|
||||
from gsd_openclaw.workspace import WorkspaceManager
|
||||
|
||||
|
||||
class TmuxSessionError(Exception):
|
||||
"""Errors related to tmux session management."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TmuxSession:
|
||||
"""Represents a managed tmux session.
|
||||
|
||||
Attributes:
|
||||
session_id: Unique identifier for this session.
|
||||
project_name: Name of the project this session belongs to.
|
||||
tmux_session_name: Name of the tmux session (gsd-{project} format).
|
||||
tmux_session: Runtime reference to the actual tmux session (optional).
|
||||
"""
|
||||
session_id: str
|
||||
project_name: str
|
||||
tmux_session_name: str
|
||||
tmux_session: Optional[libtmux.Session] = None
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
"""Check if the tmux session still exists.
|
||||
|
||||
Returns:
|
||||
True if the session exists in tmux, False otherwise.
|
||||
"""
|
||||
if self.tmux_session is None:
|
||||
return False
|
||||
try:
|
||||
# Check if session still exists in tmux by accessing its ID
|
||||
_ = self.tmux_session.session_id
|
||||
return True
|
||||
except libtmux.exc.LibTmuxException:
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionInfo:
|
||||
"""Session info for display.
|
||||
|
||||
Attributes:
|
||||
tmux_name: Name of the tmux session.
|
||||
project_name: Name of the project.
|
||||
session_id: Unique session identifier.
|
||||
created_at: ISO timestamp when session was created.
|
||||
last_active_at: ISO timestamp of last activity.
|
||||
command_count: Number of commands executed.
|
||||
display_name: User-friendly display name.
|
||||
is_orphan: True if tmux session exists but no state file.
|
||||
"""
|
||||
tmux_name: str
|
||||
project_name: str
|
||||
session_id: str
|
||||
created_at: str
|
||||
last_active_at: str
|
||||
command_count: int
|
||||
display_name: Optional[str]
|
||||
is_orphan: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CleanupResult:
|
||||
"""Result of orphan cleanup.
|
||||
|
||||
Attributes:
|
||||
cleaned_sessions: List of tmux session names that were cleaned up.
|
||||
errors: List of error messages for sessions that failed to clean.
|
||||
"""
|
||||
cleaned_sessions: List[str]
|
||||
errors: List[str]
|
||||
|
||||
|
||||
class TmuxSessionManager:
|
||||
"""Manages tmux session lifecycle using libtmux.
|
||||
|
||||
This class provides methods for creating, listing, and destroying tmux
|
||||
sessions. It handles session naming with collision detection and integrates
|
||||
with StateManager for persistence and WorkspaceManager for project isolation.
|
||||
|
||||
Attributes:
|
||||
SESSION_PREFIX: Prefix for all gsd-managed sessions ("gsd-").
|
||||
server: libtmux.Server instance for tmux operations.
|
||||
"""
|
||||
|
||||
SESSION_PREFIX = "gsd-"
|
||||
|
||||
def __init__(self, server: Optional[libtmux.Server] = None):
|
||||
"""Initialize TmuxSessionManager.
|
||||
|
||||
Args:
|
||||
server: Optional libtmux.Server instance. If not provided, a new
|
||||
server connection will be created.
|
||||
"""
|
||||
self.server = server or libtmux.Server()
|
||||
|
||||
def _generate_session_name(self, project_name: str) -> str:
|
||||
"""Generate tmux session name from project name.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project.
|
||||
|
||||
Returns:
|
||||
Session name in format "gsd-{project_name}".
|
||||
"""
|
||||
return f"{self.SESSION_PREFIX}{project_name}"
|
||||
|
||||
def _resolve_collision(self, base_name: str) -> str:
|
||||
"""Add timestamp suffix if session name already exists.
|
||||
|
||||
Args:
|
||||
base_name: The base session name to check.
|
||||
|
||||
Returns:
|
||||
The original name if no collision, or name with timestamp suffix
|
||||
in format "gsd-{project}-YYYYMMDD-HHMMSS".
|
||||
"""
|
||||
existing = self.server.sessions.filter(session_name=base_name)
|
||||
if not existing:
|
||||
return base_name
|
||||
suffix = datetime.now().strftime("-%Y%m%d-%H%M%S")
|
||||
return f"{base_name}{suffix}"
|
||||
|
||||
def _extract_project_name(self, tmux_name: str) -> str:
|
||||
"""Extract project name from tmux session name.
|
||||
|
||||
Handles both standard format (gsd-{project}) and collision format
|
||||
(gsd-{project}-YYYYMMDD-HHMMSS).
|
||||
|
||||
Args:
|
||||
tmux_name: The tmux session name.
|
||||
|
||||
Returns:
|
||||
Extracted project name.
|
||||
"""
|
||||
# Remove prefix
|
||||
name = tmux_name[len(self.SESSION_PREFIX):]
|
||||
# Remove timestamp suffix if present (format: -YYYYMMDD-HHMMSS)
|
||||
match = re.match(r'^(.+?)(-\d{8}-\d{6})?$', name)
|
||||
return match.group(1) if match else name
|
||||
|
||||
def list_sessions(self) -> List[libtmux.Session]:
|
||||
"""List all gsd-managed sessions.
|
||||
|
||||
Returns:
|
||||
List of libtmux.Session objects with names starting with "gsd-".
|
||||
"""
|
||||
return list(self.server.sessions.filter(
|
||||
session_name__startswith=self.SESSION_PREFIX
|
||||
))
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
project_name: str,
|
||||
workspace_manager: Optional[WorkspaceManager] = None,
|
||||
display_name: Optional[str] = None
|
||||
) -> TmuxSession:
|
||||
"""Create a new tmux session for a project.
|
||||
|
||||
This method:
|
||||
1. Ensures the workspace directory exists
|
||||
2. Generates a unique session name (with collision handling)
|
||||
3. Creates the tmux session (detached)
|
||||
4. Persists session state
|
||||
5. Returns a TmuxSession object
|
||||
|
||||
Args:
|
||||
project_name: Name of the project.
|
||||
workspace_manager: Optional WorkspaceManager instance. If not provided,
|
||||
a new one will be created for the project.
|
||||
display_name: Optional user-friendly display name for the session.
|
||||
|
||||
Returns:
|
||||
TmuxSession object with all metadata populated.
|
||||
"""
|
||||
# Ensure workspace exists
|
||||
ws_manager = workspace_manager or WorkspaceManager(project_name)
|
||||
ws_manager.create()
|
||||
|
||||
# Generate unique session name
|
||||
base_name = self._generate_session_name(project_name)
|
||||
tmux_name = self._resolve_collision(base_name)
|
||||
|
||||
# Create tmux session (detached)
|
||||
tmux_session = self.server.new_session(
|
||||
session_name=tmux_name,
|
||||
attach=False,
|
||||
window_name='main'
|
||||
)
|
||||
|
||||
# Create session state
|
||||
session_id = str(uuid.uuid4())[:8]
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
state = SessionState(
|
||||
session_id=session_id,
|
||||
project_name=project_name,
|
||||
tmux_session_name=tmux_name,
|
||||
tmux_pane_id=tmux_session.active_pane.pane_id,
|
||||
created_at=now,
|
||||
last_active_at=now,
|
||||
display_name=display_name
|
||||
)
|
||||
|
||||
# Persist state
|
||||
state_manager = StateManager(project_name)
|
||||
state_manager.save(state)
|
||||
|
||||
return TmuxSession(
|
||||
session_id=session_id,
|
||||
project_name=project_name,
|
||||
tmux_session_name=tmux_name,
|
||||
tmux_session=tmux_session
|
||||
)
|
||||
|
||||
def destroy_session(
|
||||
self,
|
||||
project_name: str,
|
||||
cleanup_workspace: bool = False
|
||||
) -> bool:
|
||||
"""Destroy a tmux session and optionally cleanup workspace.
|
||||
|
||||
This method:
|
||||
1. Loads session state to find tmux session name
|
||||
2. Kills the tmux session
|
||||
3. Removes state files
|
||||
4. Optionally removes the entire workspace
|
||||
|
||||
Args:
|
||||
project_name: Name of the project.
|
||||
cleanup_workspace: If True, also remove the workspace directory.
|
||||
|
||||
Returns:
|
||||
True if session was destroyed, False if no session existed.
|
||||
"""
|
||||
state_manager = StateManager(project_name)
|
||||
state = state_manager.load()
|
||||
|
||||
if state is None:
|
||||
return False # No session to destroy
|
||||
|
||||
# Kill tmux session
|
||||
try:
|
||||
tmux_session = self.server.sessions.get(
|
||||
session_name=state.tmux_session_name
|
||||
)
|
||||
if tmux_session:
|
||||
tmux_session.kill()
|
||||
except libtmux.exc.LibTmuxException:
|
||||
pass # Session already gone
|
||||
|
||||
# Remove state file
|
||||
if state_manager.state_path.exists():
|
||||
state_manager.state_path.unlink()
|
||||
backup_path = state_manager.state_path.with_suffix('.json.bak')
|
||||
if backup_path.exists():
|
||||
backup_path.unlink()
|
||||
|
||||
# Optionally cleanup workspace
|
||||
if cleanup_workspace:
|
||||
workspace_manager = WorkspaceManager(project_name)
|
||||
workspace_manager.destroy()
|
||||
|
||||
return True
|
||||
|
||||
def list_gsd_sessions(self) -> List[SessionInfo]:
|
||||
"""List all gsd sessions with project associations.
|
||||
|
||||
This method combines tmux session info with persisted state to provide
|
||||
a complete view of all gsd sessions.
|
||||
|
||||
Returns:
|
||||
List of SessionInfo objects for all gsd-* sessions.
|
||||
"""
|
||||
sessions_info = []
|
||||
|
||||
# Get all gsd-* tmux sessions
|
||||
tmux_sessions = self.list_sessions()
|
||||
|
||||
for tmux_session in tmux_sessions:
|
||||
tmux_name = tmux_session.name
|
||||
# Extract project name from tmux session name
|
||||
project_name = self._extract_project_name(tmux_name)
|
||||
|
||||
# Try to load state
|
||||
state_manager = StateManager(project_name)
|
||||
state = state_manager.load()
|
||||
|
||||
if state and state.tmux_session_name == tmux_name:
|
||||
# Matched session
|
||||
sessions_info.append(SessionInfo(
|
||||
tmux_name=tmux_name,
|
||||
project_name=project_name,
|
||||
session_id=state.session_id,
|
||||
created_at=state.created_at,
|
||||
last_active_at=state.last_active_at,
|
||||
command_count=state.command_count,
|
||||
display_name=state.display_name,
|
||||
is_orphan=False
|
||||
))
|
||||
else:
|
||||
# Orphan session (no matching state)
|
||||
sessions_info.append(SessionInfo(
|
||||
tmux_name=tmux_name,
|
||||
project_name=project_name,
|
||||
session_id="?",
|
||||
created_at="?",
|
||||
last_active_at="?",
|
||||
command_count=0,
|
||||
display_name=None,
|
||||
is_orphan=True
|
||||
))
|
||||
|
||||
return sessions_info
|
||||
|
||||
def get_session(self, project_name: str) -> Optional[TmuxSession]:
|
||||
"""Get existing session without creating new one.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project.
|
||||
|
||||
Returns:
|
||||
TmuxSession if state exists, None otherwise.
|
||||
"""
|
||||
state_manager = StateManager(project_name)
|
||||
state = state_manager.load()
|
||||
|
||||
if state is None:
|
||||
return None
|
||||
|
||||
# Find tmux session
|
||||
tmux_session = None
|
||||
try:
|
||||
tmux_session = self.server.sessions.get(
|
||||
session_name=state.tmux_session_name
|
||||
)
|
||||
except libtmux.exc.LibTmuxException:
|
||||
pass
|
||||
|
||||
return TmuxSession(
|
||||
session_id=state.session_id,
|
||||
project_name=project_name,
|
||||
tmux_session_name=state.tmux_session_name,
|
||||
tmux_session=tmux_session
|
||||
)
|
||||
|
||||
def resume_session(self, project_name: str) -> TmuxSession:
|
||||
"""Resume an existing session, recreating tmux if needed.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project.
|
||||
|
||||
Returns:
|
||||
TmuxSession with all metadata populated.
|
||||
|
||||
Raises:
|
||||
TmuxSessionError: If no session found, unsupported state version,
|
||||
or workspace missing.
|
||||
"""
|
||||
state_manager = StateManager(project_name)
|
||||
state = state_manager.load()
|
||||
|
||||
if state is None:
|
||||
raise TmuxSessionError(f"No session found for project: {project_name}")
|
||||
|
||||
# Validate state
|
||||
if state.schema_version != "1.0":
|
||||
raise TmuxSessionError(
|
||||
f"Unsupported state version: {state.schema_version}"
|
||||
)
|
||||
|
||||
# Check if tmux session exists
|
||||
tmux_session = None
|
||||
try:
|
||||
tmux_session = self.server.sessions.get(
|
||||
session_name=state.tmux_session_name
|
||||
)
|
||||
except libtmux.exc.LibTmuxException:
|
||||
pass
|
||||
|
||||
# Recreate tmux session if missing
|
||||
if tmux_session is None:
|
||||
workspace_manager = WorkspaceManager(project_name)
|
||||
if not workspace_manager.exists():
|
||||
raise TmuxSessionError(
|
||||
f"Workspace missing for project: {project_name}"
|
||||
)
|
||||
# Create new tmux session with same name
|
||||
tmux_session = self.server.new_session(
|
||||
session_name=state.tmux_session_name,
|
||||
attach=False,
|
||||
window_name='main',
|
||||
start_directory=str(workspace_manager.get_workspace().root)
|
||||
)
|
||||
# Update pane ID in state
|
||||
state.tmux_pane_id = tmux_session.active_pane.pane_id
|
||||
state.last_active_at = datetime.now().isoformat()
|
||||
state_manager.save(state)
|
||||
|
||||
return TmuxSession(
|
||||
session_id=state.session_id,
|
||||
project_name=project_name,
|
||||
tmux_session_name=state.tmux_session_name,
|
||||
tmux_session=tmux_session
|
||||
)
|
||||
|
||||
def cleanup_orphans(self) -> CleanupResult:
|
||||
"""Remove tmux sessions without corresponding state files.
|
||||
|
||||
This method identifies orphan tmux sessions (gsd-* sessions without
|
||||
valid state files) and kills them.
|
||||
|
||||
Returns:
|
||||
CleanupResult with lists of cleaned sessions and errors.
|
||||
"""
|
||||
cleaned = []
|
||||
errors = []
|
||||
|
||||
# Get all gsd-* tmux sessions
|
||||
tmux_sessions = self.list_sessions()
|
||||
|
||||
for tmux_session in tmux_sessions:
|
||||
tmux_name = tmux_session.name
|
||||
project_name = self._extract_project_name(tmux_name)
|
||||
|
||||
# Check for state file
|
||||
state_manager = StateManager(project_name)
|
||||
state = state_manager.load()
|
||||
|
||||
should_clean = False
|
||||
|
||||
if state is None:
|
||||
# No state file - orphan
|
||||
should_clean = True
|
||||
elif state.tmux_session_name != tmux_name:
|
||||
# State exists but references different tmux session
|
||||
# This could be a collision case, treat as orphan
|
||||
should_clean = True
|
||||
|
||||
if should_clean:
|
||||
try:
|
||||
tmux_session.kill()
|
||||
cleaned.append(tmux_name)
|
||||
except libtmux.exc.LibTmuxException as e:
|
||||
errors.append(f"Failed to kill {tmux_name}: {e}")
|
||||
|
||||
return CleanupResult(cleaned_sessions=cleaned, errors=errors)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Workspace directory management for project isolation."""
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import shutil
|
||||
|
||||
|
||||
@dataclass
|
||||
class Workspace:
|
||||
"""Represents a project workspace with defined directory structure.
|
||||
|
||||
Each project has its own isolated workspace directory following the layout:
|
||||
~/gsd-workspaces/{project}/
|
||||
├── .gsd/
|
||||
│ ├── state.json # Session state
|
||||
│ ├── state.json.bak # State backup
|
||||
│ └── session-dump/ # Session dump
|
||||
├── code/ # Project code (optional)
|
||||
└── io-logs/
|
||||
└── {session-id}.log # I/O logs
|
||||
"""
|
||||
|
||||
project_name: str
|
||||
root: Optional[Path] = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Compute root path if not provided."""
|
||||
if self.root is None:
|
||||
self.root = Path.home() / "gsd-workspaces" / self.project_name
|
||||
elif isinstance(self.root, str):
|
||||
self.root = Path(self.root)
|
||||
|
||||
@property
|
||||
def gsd_dir(self) -> Path:
|
||||
"""Path to .gsd directory containing state files."""
|
||||
return self.root / ".gsd"
|
||||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
"""Path to state.json file."""
|
||||
return self.gsd_dir / "state.json"
|
||||
|
||||
@property
|
||||
def state_backup_path(self) -> Path:
|
||||
"""Path to state.json.bak file."""
|
||||
return self.gsd_dir / "state.json.bak"
|
||||
|
||||
@property
|
||||
def session_dump_dir(self) -> Path:
|
||||
"""Path to session-dump directory."""
|
||||
return self.gsd_dir / "session-dump"
|
||||
|
||||
@property
|
||||
def code_dir(self) -> Path:
|
||||
"""Path to code directory."""
|
||||
return self.root / "code"
|
||||
|
||||
@property
|
||||
def io_logs_dir(self) -> Path:
|
||||
"""Path to io-logs directory."""
|
||||
return self.root / "io-logs"
|
||||
|
||||
def io_log_path(self, session_id: str) -> Path:
|
||||
"""Get path to I/O log file for a specific session.
|
||||
|
||||
Args:
|
||||
session_id: The session identifier.
|
||||
|
||||
Returns:
|
||||
Path to the session's I/O log file.
|
||||
"""
|
||||
return self.io_logs_dir / f"{session_id}.log"
|
||||
|
||||
|
||||
class WorkspaceManager:
|
||||
"""Manages workspace creation, destruction, and lifecycle.
|
||||
|
||||
The WorkspaceManager handles:
|
||||
- Creating workspace directory structure
|
||||
- Destroying workspaces (cleanup)
|
||||
- Checking workspace existence
|
||||
- Providing workspace objects
|
||||
"""
|
||||
|
||||
WORKSPACES_ROOT = Path.home() / "gsd-workspaces"
|
||||
|
||||
def __init__(self, project_name: str, root: Optional[Path] = None):
|
||||
"""Initialize workspace manager.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project (used for workspace directory).
|
||||
root: Optional custom root path for workspaces (useful for testing).
|
||||
"""
|
||||
self.project_name = project_name
|
||||
self.root = root or self.WORKSPACES_ROOT
|
||||
if isinstance(self.root, str):
|
||||
self.root = Path(self.root)
|
||||
self.workspace = Workspace(project_name, self.root / project_name)
|
||||
|
||||
def create(self) -> Workspace:
|
||||
"""Create workspace directory structure.
|
||||
|
||||
Creates all required directories:
|
||||
- .gsd/ (with session-dump/ subdirectory)
|
||||
- code/
|
||||
- io-logs/
|
||||
|
||||
This operation is idempotent - calling multiple times will not fail.
|
||||
|
||||
Returns:
|
||||
The Workspace object with paths configured.
|
||||
"""
|
||||
# Create all directories
|
||||
self.workspace.gsd_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.workspace.session_dump_dir.mkdir(exist_ok=True)
|
||||
self.workspace.code_dir.mkdir(exist_ok=True)
|
||||
self.workspace.io_logs_dir.mkdir(exist_ok=True)
|
||||
return self.workspace
|
||||
|
||||
def destroy(self) -> None:
|
||||
"""Remove entire workspace directory.
|
||||
|
||||
Completely removes the workspace and all its contents.
|
||||
Safe to call even if workspace doesn't exist.
|
||||
"""
|
||||
if self.workspace.root.exists():
|
||||
shutil.rmtree(self.workspace.root)
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Check if workspace exists.
|
||||
|
||||
Returns:
|
||||
True if workspace root directory exists, False otherwise.
|
||||
"""
|
||||
return self.workspace.root.exists()
|
||||
|
||||
def get_workspace(self) -> Workspace:
|
||||
"""Get workspace object (does not create).
|
||||
|
||||
Returns:
|
||||
The Workspace object with paths configured.
|
||||
"""
|
||||
return self.workspace
|
||||
Reference in New Issue
Block a user