emdash-cli 0.1.30__py3-none-any.whl → 0.1.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. emdash_cli/__init__.py +15 -0
  2. emdash_cli/client.py +156 -0
  3. emdash_cli/clipboard.py +30 -61
  4. emdash_cli/commands/agent/__init__.py +14 -0
  5. emdash_cli/commands/agent/cli.py +100 -0
  6. emdash_cli/commands/agent/constants.py +53 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +41 -0
  9. emdash_cli/commands/agent/handlers/agents.py +421 -0
  10. emdash_cli/commands/agent/handlers/auth.py +69 -0
  11. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  12. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  13. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  14. emdash_cli/commands/agent/handlers/misc.py +200 -0
  15. emdash_cli/commands/agent/handlers/rules.py +394 -0
  16. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  17. emdash_cli/commands/agent/handlers/setup.py +582 -0
  18. emdash_cli/commands/agent/handlers/skills.py +440 -0
  19. emdash_cli/commands/agent/handlers/todos.py +98 -0
  20. emdash_cli/commands/agent/handlers/verify.py +648 -0
  21. emdash_cli/commands/agent/interactive.py +657 -0
  22. emdash_cli/commands/agent/menus.py +728 -0
  23. emdash_cli/commands/agent.py +7 -856
  24. emdash_cli/commands/server.py +99 -40
  25. emdash_cli/server_manager.py +70 -10
  26. emdash_cli/session_store.py +321 -0
  27. emdash_cli/sse_renderer.py +256 -110
  28. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/METADATA +2 -4
  29. emdash_cli-0.1.46.dist-info/RECORD +49 -0
  30. emdash_cli-0.1.30.dist-info/RECORD +0 -29
  31. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/WHEEL +0 -0
  32. {emdash_cli-0.1.30.dist-info → emdash_cli-0.1.46.dist-info}/entry_points.txt +0 -0
@@ -10,6 +10,9 @@ from rich.console import Console
10
10
 
11
11
  console = Console()
12
12
 
13
+ # Per-repo servers directory
14
+ SERVERS_DIR = Path.home() / ".emdash" / "servers"
15
+
13
16
 
14
17
  @click.group()
15
18
  def server():
@@ -26,23 +29,41 @@ def server_killall():
26
29
  """
27
30
  killed = 0
28
31
 
29
- # Kill by PID file first
30
- pid_file = Path.home() / ".emdash" / "server.pid"
31
- if pid_file.exists():
32
+ # Kill servers by PID files in servers directory
33
+ if SERVERS_DIR.exists():
34
+ for pid_file in SERVERS_DIR.glob("*.pid"):
35
+ try:
36
+ pid = int(pid_file.read_text().strip())
37
+ os.kill(pid, signal.SIGTERM)
38
+ console.print(f"[green]Killed server process {pid}[/green]")
39
+ killed += 1
40
+ except (ValueError, ProcessLookupError, PermissionError):
41
+ pass
42
+ finally:
43
+ # Clean up all files for this server
44
+ hash_prefix = pid_file.stem
45
+ for ext in [".port", ".pid", ".repo"]:
46
+ server_file = SERVERS_DIR / f"{hash_prefix}{ext}"
47
+ if server_file.exists():
48
+ server_file.unlink(missing_ok=True)
49
+
50
+ # Also check legacy location
51
+ legacy_pid_file = Path.home() / ".emdash" / "server.pid"
52
+ if legacy_pid_file.exists():
32
53
  try:
33
- pid = int(pid_file.read_text().strip())
54
+ pid = int(legacy_pid_file.read_text().strip())
34
55
  os.kill(pid, signal.SIGTERM)
35
- console.print(f"[green]Killed server process {pid}[/green]")
56
+ console.print(f"[green]Killed legacy server process {pid}[/green]")
36
57
  killed += 1
37
58
  except (ValueError, ProcessLookupError, PermissionError):
38
59
  pass
39
60
  finally:
40
- pid_file.unlink(missing_ok=True)
61
+ legacy_pid_file.unlink(missing_ok=True)
41
62
 
42
- # Clean up port file
43
- port_file = Path.home() / ".emdash" / "server.port"
44
- if port_file.exists():
45
- port_file.unlink(missing_ok=True)
63
+ # Clean up legacy port file
64
+ legacy_port_file = Path.home() / ".emdash" / "server.port"
65
+ if legacy_port_file.exists():
66
+ legacy_port_file.unlink(missing_ok=True)
46
67
 
47
68
  # Kill any remaining emdash_core.server processes
48
69
  try:
@@ -77,41 +98,79 @@ def server_killall():
77
98
 
78
99
  @server.command("status")
79
100
  def server_status():
80
- """Show server status.
101
+ """Show status of all running servers.
81
102
 
82
103
  Example:
83
104
  emdash server status
84
105
  """
85
- port_file = Path.home() / ".emdash" / "server.port"
86
- pid_file = Path.home() / ".emdash" / "server.pid"
106
+ import httpx
87
107
 
88
- if not port_file.exists():
89
- console.print("[yellow]No server running[/yellow]")
90
- return
108
+ servers_found = []
91
109
 
92
- try:
93
- port = int(port_file.read_text().strip())
94
- except (ValueError, IOError):
95
- console.print("[yellow]No server running (invalid port file)[/yellow]")
96
- return
110
+ # Check per-repo servers directory
111
+ if SERVERS_DIR.exists():
112
+ for port_file in SERVERS_DIR.glob("*.port"):
113
+ try:
114
+ port = int(port_file.read_text().strip())
115
+ hash_prefix = port_file.stem
97
116
 
98
- # Check if server is responsive
99
- import httpx
100
- try:
101
- response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
102
- if response.status_code == 200:
103
- pid = "unknown"
104
- if pid_file.exists():
117
+ # Get repo path if available
118
+ repo_file = SERVERS_DIR / f"{hash_prefix}.repo"
119
+ repo_path = repo_file.read_text().strip() if repo_file.exists() else "unknown"
120
+
121
+ # Get PID if available
122
+ pid_file = SERVERS_DIR / f"{hash_prefix}.pid"
123
+ pid = pid_file.read_text().strip() if pid_file.exists() else "unknown"
124
+
125
+ # Check health
105
126
  try:
106
- pid = pid_file.read_text().strip()
107
- except IOError:
108
- pass
109
-
110
- console.print(f"[bold green]Server running[/bold green]")
111
- console.print(f" Port: {port}")
112
- console.print(f" PID: {pid}")
113
- console.print(f" URL: http://localhost:{port}")
114
- else:
115
- console.print(f"[yellow]Server on port {port} not healthy[/yellow]")
116
- except (httpx.RequestError, httpx.TimeoutException):
117
- console.print(f"[yellow]Server on port {port} not responding[/yellow]")
127
+ response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
128
+ healthy = response.status_code == 200
129
+ except (httpx.RequestError, httpx.TimeoutException):
130
+ healthy = False
131
+
132
+ servers_found.append({
133
+ "port": port,
134
+ "pid": pid,
135
+ "repo": repo_path,
136
+ "healthy": healthy,
137
+ })
138
+ except (ValueError, IOError):
139
+ pass
140
+
141
+ # Check legacy location
142
+ legacy_port_file = Path.home() / ".emdash" / "server.port"
143
+ if legacy_port_file.exists():
144
+ try:
145
+ port = int(legacy_port_file.read_text().strip())
146
+ legacy_pid_file = Path.home() / ".emdash" / "server.pid"
147
+ pid = legacy_pid_file.read_text().strip() if legacy_pid_file.exists() else "unknown"
148
+
149
+ try:
150
+ response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
151
+ healthy = response.status_code == 200
152
+ except (httpx.RequestError, httpx.TimeoutException):
153
+ healthy = False
154
+
155
+ servers_found.append({
156
+ "port": port,
157
+ "pid": pid,
158
+ "repo": "(legacy)",
159
+ "healthy": healthy,
160
+ })
161
+ except (ValueError, IOError):
162
+ pass
163
+
164
+ if not servers_found:
165
+ console.print("[yellow]No servers running[/yellow]")
166
+ return
167
+
168
+ console.print(f"[bold]Found {len(servers_found)} server(s):[/bold]\n")
169
+ for srv in servers_found:
170
+ status = "[green]healthy[/green]" if srv["healthy"] else "[red]unhealthy[/red]"
171
+ console.print(f" {status}")
172
+ console.print(f" Port: {srv['port']}")
173
+ console.print(f" PID: {srv['pid']}")
174
+ console.print(f" Repo: {srv['repo']}")
175
+ console.print(f" URL: http://localhost:{srv['port']}")
176
+ console.print()
@@ -1,6 +1,7 @@
1
1
  """Server lifecycle management for emdash-core."""
2
2
 
3
3
  import atexit
4
+ import hashlib
4
5
  import os
5
6
  import signal
6
7
  import socket
@@ -17,15 +18,15 @@ class ServerManager:
17
18
  """Manages FastAPI server lifecycle for CLI.
18
19
 
19
20
  The ServerManager handles:
21
+ - Per-repo server instances (each repo gets its own server)
20
22
  - Discovering running servers via port file
21
23
  - Starting new servers when needed
22
24
  - Health checking servers
23
25
  - Graceful shutdown on CLI exit
26
+ - Cleanup of stale servers
24
27
  """
25
28
 
26
- DEFAULT_PORT = 8765
27
- PORT_FILE = Path.home() / ".emdash" / "server.port"
28
- PID_FILE = Path.home() / ".emdash" / "server.pid"
29
+ SERVERS_DIR = Path.home() / ".emdash" / "servers"
29
30
  STARTUP_TIMEOUT = 30.0 # seconds
30
31
  HEALTH_TIMEOUT = 2.0 # seconds
31
32
 
@@ -40,8 +41,35 @@ class ServerManager:
40
41
  self.port: Optional[int] = None
41
42
  self._started_by_us = False
42
43
 
44
+ # Create servers directory
45
+ self.SERVERS_DIR.mkdir(parents=True, exist_ok=True)
46
+
47
+ # Cleanup stale servers on init
48
+ self._cleanup_stale_servers()
49
+
50
+ @property
51
+ def _repo_hash(self) -> str:
52
+ """Get a short hash of the repo root path for unique file naming."""
53
+ path_str = str(self.repo_root.resolve())
54
+ return hashlib.sha256(path_str.encode()).hexdigest()[:12]
55
+
56
+ @property
57
+ def _port_file(self) -> Path:
58
+ """Get the port file path for this repo."""
59
+ return self.SERVERS_DIR / f"{self._repo_hash}.port"
60
+
61
+ @property
62
+ def _pid_file(self) -> Path:
63
+ """Get the PID file path for this repo."""
64
+ return self.SERVERS_DIR / f"{self._repo_hash}.pid"
65
+
66
+ @property
67
+ def _repo_file(self) -> Path:
68
+ """Get the repo path file for this repo (for debugging)."""
69
+ return self.SERVERS_DIR / f"{self._repo_hash}.repo"
70
+
43
71
  def get_server_url(self) -> str:
44
- """Get URL of running server, starting one if needed.
72
+ """Get URL of running server for this repo, starting one if needed.
45
73
 
46
74
  Returns:
47
75
  Base URL of the running server (e.g., "http://localhost:8765")
@@ -49,17 +77,19 @@ class ServerManager:
49
77
  Raises:
50
78
  RuntimeError: If server fails to start
51
79
  """
52
- # Check if server already running
53
- if self.PORT_FILE.exists():
80
+ # Check if server already running for THIS repo
81
+ if self._port_file.exists():
54
82
  try:
55
- port = int(self.PORT_FILE.read_text().strip())
83
+ port = int(self._port_file.read_text().strip())
56
84
  if self._check_health(port):
57
85
  self.port = port
58
86
  return f"http://localhost:{port}"
59
87
  except (ValueError, IOError):
60
88
  pass
89
+ # Server not healthy, clean up stale files
90
+ self._cleanup_files()
61
91
 
62
- # Start new server
92
+ # Start new server for this repo
63
93
  self.port = self._find_free_port()
64
94
  self._spawn_server()
65
95
  return f"http://localhost:{self.port}"
@@ -133,6 +163,11 @@ class ServerManager:
133
163
 
134
164
  self._started_by_us = True
135
165
 
166
+ # Write port, PID, and repo files
167
+ self._port_file.write_text(str(self.port))
168
+ self._pid_file.write_text(str(self.process.pid))
169
+ self._repo_file.write_text(str(self.repo_root))
170
+
136
171
  # Register cleanup for normal exit
137
172
  atexit.register(self.shutdown)
138
173
 
@@ -203,14 +238,39 @@ class ServerManager:
203
238
  )
204
239
 
205
240
  def _cleanup_files(self) -> None:
206
- """Clean up port and PID files."""
207
- for file in [self.PORT_FILE, self.PID_FILE]:
241
+ """Clean up port, PID, and repo files for this repo."""
242
+ for file in [self._port_file, self._pid_file, self._repo_file]:
208
243
  try:
209
244
  if file.exists():
210
245
  file.unlink()
211
246
  except IOError:
212
247
  pass
213
248
 
249
+ def _cleanup_stale_servers(self) -> None:
250
+ """Clean up stale server files where process no longer exists."""
251
+ if not self.SERVERS_DIR.exists():
252
+ return
253
+
254
+ for pid_file in self.SERVERS_DIR.glob("*.pid"):
255
+ try:
256
+ pid = int(pid_file.read_text().strip())
257
+ # Check if process exists
258
+ try:
259
+ os.kill(pid, 0) # Signal 0 checks if process exists
260
+ except OSError:
261
+ # Process doesn't exist, clean up files
262
+ hash_prefix = pid_file.stem
263
+ for ext in [".port", ".pid", ".repo"]:
264
+ stale_file = self.SERVERS_DIR / f"{hash_prefix}{ext}"
265
+ if stale_file.exists():
266
+ stale_file.unlink()
267
+ except (ValueError, IOError):
268
+ # Invalid PID file, remove it
269
+ try:
270
+ pid_file.unlink()
271
+ except IOError:
272
+ pass
273
+
214
274
 
215
275
  # Global singleton for CLI commands
216
276
  _server_manager: Optional[ServerManager] = None
@@ -0,0 +1,321 @@
1
+ """Session persistence for CLI conversations.
2
+
3
+ Manages saving and loading of conversation sessions to .emdash/sessions/.
4
+ Each session preserves messages, mode, and other state for later restoration.
5
+ """
6
+
7
+ import json
8
+ import re
9
+ from dataclasses import dataclass, asdict
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+
15
+ MAX_SESSIONS = 5
16
+ MAX_MESSAGES = 10
17
+ SESSION_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,49}$")
18
+
19
+
20
+ @dataclass
21
+ class SessionMetadata:
22
+ """Metadata for a saved session."""
23
+ name: str
24
+ created_at: str
25
+ updated_at: str
26
+ message_count: int
27
+ model: Optional[str] = None
28
+ mode: str = "code"
29
+ summary: Optional[str] = None
30
+
31
+
32
+ @dataclass
33
+ class SessionData:
34
+ """Full session data including messages."""
35
+ name: str
36
+ messages: list[dict]
37
+ mode: str
38
+ model: Optional[str] = None
39
+ spec: Optional[str] = None
40
+ created_at: str = ""
41
+ updated_at: str = ""
42
+
43
+ def to_dict(self) -> dict:
44
+ return asdict(self)
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: dict) -> "SessionData":
48
+ return cls(
49
+ name=data.get("name", ""),
50
+ messages=data.get("messages", []),
51
+ mode=data.get("mode", "code"),
52
+ model=data.get("model"),
53
+ spec=data.get("spec"),
54
+ created_at=data.get("created_at", ""),
55
+ updated_at=data.get("updated_at", ""),
56
+ )
57
+
58
+
59
+ class SessionStore:
60
+ """File-based session storage.
61
+
62
+ Sessions are stored in .emdash/sessions/ with:
63
+ - index.json: metadata for all sessions
64
+ - {name}.json: individual session data
65
+
66
+ Example:
67
+ store = SessionStore()
68
+ store.save_session("my-feature", messages, "code", None, "gpt-4")
69
+ session = store.load_session("my-feature")
70
+ """
71
+
72
+ def __init__(self, repo_root: Optional[Path] = None):
73
+ """Initialize session store.
74
+
75
+ Args:
76
+ repo_root: Repository root (defaults to cwd)
77
+ """
78
+ self.repo_root = repo_root or Path.cwd()
79
+ self.sessions_dir = self.repo_root / ".emdash" / "sessions"
80
+
81
+ def _ensure_dir(self) -> None:
82
+ """Ensure sessions directory exists."""
83
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ def _index_path(self) -> Path:
86
+ """Get path to index file."""
87
+ return self.sessions_dir / "index.json"
88
+
89
+ def _session_path(self, name: str) -> Path:
90
+ """Get path to session file."""
91
+ return self.sessions_dir / f"{name}.json"
92
+
93
+ def _load_index(self) -> dict:
94
+ """Load session index."""
95
+ index_path = self._index_path()
96
+ if index_path.exists():
97
+ try:
98
+ return json.loads(index_path.read_text())
99
+ except (json.JSONDecodeError, IOError):
100
+ pass
101
+ return {"sessions": [], "active": None}
102
+
103
+ def _save_index(self, index: dict) -> None:
104
+ """Save session index."""
105
+ self._ensure_dir()
106
+ self._index_path().write_text(json.dumps(index, indent=2))
107
+
108
+ def _validate_name(self, name: str) -> bool:
109
+ """Validate session name.
110
+
111
+ Args:
112
+ name: Session name to validate
113
+
114
+ Returns:
115
+ True if valid
116
+ """
117
+ return bool(SESSION_NAME_PATTERN.match(name))
118
+
119
+ def _generate_summary(self, messages: list[dict]) -> str:
120
+ """Generate a brief summary from messages.
121
+
122
+ Args:
123
+ messages: List of message dicts
124
+
125
+ Returns:
126
+ Summary string (first user message truncated)
127
+ """
128
+ for msg in messages:
129
+ if msg.get("role") == "user":
130
+ content = msg.get("content", "")
131
+ if isinstance(content, str) and content:
132
+ return content[:100] + ("..." if len(content) > 100 else "")
133
+ return "No description"
134
+
135
+ def list_sessions(self) -> list[SessionMetadata]:
136
+ """List all saved sessions.
137
+
138
+ Returns:
139
+ List of session metadata, sorted by updated_at (newest first)
140
+ """
141
+ index = self._load_index()
142
+ sessions = []
143
+ for s in index.get("sessions", []):
144
+ sessions.append(SessionMetadata(
145
+ name=s.get("name", ""),
146
+ created_at=s.get("created_at", ""),
147
+ updated_at=s.get("updated_at", ""),
148
+ message_count=s.get("message_count", 0),
149
+ model=s.get("model"),
150
+ mode=s.get("mode", "code"),
151
+ summary=s.get("summary"),
152
+ ))
153
+ # Sort by updated_at descending
154
+ sessions.sort(key=lambda x: x.updated_at, reverse=True)
155
+ return sessions
156
+
157
+ def save_session(
158
+ self,
159
+ name: str,
160
+ messages: list[dict],
161
+ mode: str,
162
+ spec: Optional[str] = None,
163
+ model: Optional[str] = None,
164
+ ) -> tuple[bool, str]:
165
+ """Save a session.
166
+
167
+ Args:
168
+ name: Session name (alphanumeric, hyphens, underscores)
169
+ messages: Conversation messages
170
+ mode: Current mode (plan/code)
171
+ spec: Current spec if any
172
+ model: Model being used
173
+
174
+ Returns:
175
+ Tuple of (success, message)
176
+ """
177
+ # Validate name
178
+ if not self._validate_name(name):
179
+ return False, "Invalid session name. Use letters, numbers, hyphens, underscores (max 50 chars)"
180
+
181
+ # Load index
182
+ index = self._load_index()
183
+ sessions = index.get("sessions", [])
184
+
185
+ # Check if updating existing session
186
+ existing_idx = None
187
+ for i, s in enumerate(sessions):
188
+ if s.get("name") == name:
189
+ existing_idx = i
190
+ break
191
+
192
+ # Check limit for new sessions
193
+ if existing_idx is None and len(sessions) >= MAX_SESSIONS:
194
+ return False, f"Maximum {MAX_SESSIONS} sessions reached. Delete one first with /session delete <name>"
195
+
196
+ now = datetime.utcnow().isoformat() + "Z"
197
+
198
+ # Trim messages to most recent N
199
+ trimmed_messages = messages[-MAX_MESSAGES:] if len(messages) > MAX_MESSAGES else messages
200
+
201
+ # Create session data
202
+ session_data = SessionData(
203
+ name=name,
204
+ messages=trimmed_messages,
205
+ mode=mode,
206
+ model=model,
207
+ spec=spec,
208
+ created_at=sessions[existing_idx]["created_at"] if existing_idx is not None else now,
209
+ updated_at=now,
210
+ )
211
+
212
+ # Save session file
213
+ self._ensure_dir()
214
+ self._session_path(name).write_text(json.dumps(session_data.to_dict(), indent=2))
215
+
216
+ # Update index
217
+ metadata = {
218
+ "name": name,
219
+ "created_at": session_data.created_at,
220
+ "updated_at": session_data.updated_at,
221
+ "message_count": len(trimmed_messages),
222
+ "model": model,
223
+ "mode": mode,
224
+ "summary": self._generate_summary(messages),
225
+ }
226
+
227
+ if existing_idx is not None:
228
+ sessions[existing_idx] = metadata
229
+ else:
230
+ sessions.append(metadata)
231
+
232
+ index["sessions"] = sessions
233
+ self._save_index(index)
234
+
235
+ return True, f"Session '{name}' saved ({len(trimmed_messages)} messages)"
236
+
237
+ def load_session(self, name: str) -> Optional[SessionData]:
238
+ """Load a session by name.
239
+
240
+ Args:
241
+ name: Session name
242
+
243
+ Returns:
244
+ SessionData or None if not found
245
+ """
246
+ session_path = self._session_path(name)
247
+ if not session_path.exists():
248
+ return None
249
+
250
+ try:
251
+ data = json.loads(session_path.read_text())
252
+ return SessionData.from_dict(data)
253
+ except (json.JSONDecodeError, IOError):
254
+ return None
255
+
256
+ def delete_session(self, name: str) -> tuple[bool, str]:
257
+ """Delete a session.
258
+
259
+ Args:
260
+ name: Session name
261
+
262
+ Returns:
263
+ Tuple of (success, message)
264
+ """
265
+ index = self._load_index()
266
+ sessions = index.get("sessions", [])
267
+
268
+ # Find and remove from index
269
+ found = False
270
+ for i, s in enumerate(sessions):
271
+ if s.get("name") == name:
272
+ sessions.pop(i)
273
+ found = True
274
+ break
275
+
276
+ if not found:
277
+ return False, f"Session '{name}' not found"
278
+
279
+ # Delete session file
280
+ session_path = self._session_path(name)
281
+ if session_path.exists():
282
+ session_path.unlink()
283
+
284
+ # Clear active if it was this session
285
+ if index.get("active") == name:
286
+ index["active"] = None
287
+
288
+ index["sessions"] = sessions
289
+ self._save_index(index)
290
+
291
+ return True, f"Session '{name}' deleted"
292
+
293
+ def get_active_session(self) -> Optional[str]:
294
+ """Get the name of the active session.
295
+
296
+ Returns:
297
+ Session name or None
298
+ """
299
+ index = self._load_index()
300
+ return index.get("active")
301
+
302
+ def set_active_session(self, name: Optional[str]) -> None:
303
+ """Set the active session.
304
+
305
+ Args:
306
+ name: Session name or None to clear
307
+ """
308
+ index = self._load_index()
309
+ index["active"] = name
310
+ self._save_index(index)
311
+
312
+ def session_exists(self, name: str) -> bool:
313
+ """Check if a session exists.
314
+
315
+ Args:
316
+ name: Session name
317
+
318
+ Returns:
319
+ True if session exists
320
+ """
321
+ return self._session_path(name).exists()