claude-mpm 4.10.0__py3-none-any.whl → 4.11.0__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.
@@ -0,0 +1,389 @@
1
+ """
2
+ Session Pause Manager - Captures and saves session state for later resumption.
3
+
4
+ This module provides functionality to pause a Claude MPM session by capturing:
5
+ - Conversation context and progress
6
+ - Git repository state
7
+ - Todo list status
8
+ - Working directory changes
9
+
10
+ The saved state enables seamless session resumption with full context.
11
+ """
12
+
13
+ import subprocess
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+
21
+ from claude_mpm.core.logging_utils import get_logger
22
+ from claude_mpm.storage.state_storage import StateStorage
23
+
24
+ logger = get_logger(__name__)
25
+ console = Console()
26
+
27
+
28
+ class SessionPauseManager:
29
+ """Manages pausing and saving session state."""
30
+
31
+ def __init__(self, project_path: Path):
32
+ """Initialize Session Pause Manager.
33
+
34
+ Args:
35
+ project_path: Path to the project directory
36
+ """
37
+ self.project_path = project_path
38
+ self.session_dir = project_path / ".claude-mpm" / "sessions" / "pause"
39
+ self.storage = StateStorage()
40
+
41
+ def pause_session(
42
+ self,
43
+ conversation_summary: Optional[str] = None,
44
+ accomplishments: Optional[List[str]] = None,
45
+ next_steps: Optional[List[str]] = None,
46
+ todos_active: Optional[List[Dict[str, str]]] = None,
47
+ todos_completed: Optional[List[Dict[str, str]]] = None,
48
+ ) -> Dict[str, Any]:
49
+ """Pause the current session and save state.
50
+
51
+ Args:
52
+ conversation_summary: Summary of what was being worked on
53
+ accomplishments: List of things accomplished in this session
54
+ next_steps: List of next steps to continue work
55
+ todos_active: Active todo items
56
+ todos_completed: Completed todo items
57
+
58
+ Returns:
59
+ Dict containing pause result with session_file path
60
+ """
61
+ try:
62
+ # Ensure session directory exists
63
+ self.session_dir.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Generate session ID
66
+ timestamp = datetime.now(timezone.utc)
67
+ session_id = f"session-{timestamp.strftime('%Y%m%d-%H%M%S')}"
68
+
69
+ # Capture git context
70
+ git_context = self._capture_git_context()
71
+
72
+ # Build session state
73
+ session_state = {
74
+ "session_id": session_id,
75
+ "paused_at": timestamp.isoformat(),
76
+ "conversation": {
77
+ "summary": conversation_summary
78
+ or "Session paused - context not provided",
79
+ "accomplishments": accomplishments or [],
80
+ "next_steps": next_steps or [],
81
+ },
82
+ "git_context": git_context,
83
+ "todos": {
84
+ "active": todos_active or [],
85
+ "completed": todos_completed or [],
86
+ },
87
+ "version": self._get_version(),
88
+ "build": self._get_build_number(),
89
+ "project_path": str(self.project_path),
90
+ }
91
+
92
+ # Save session state to file
93
+ session_file = self.session_dir / f"{session_id}.json"
94
+ success = self.storage.write_json(
95
+ session_state, session_file, atomic=True, compress=False
96
+ )
97
+
98
+ if not success:
99
+ return {
100
+ "status": "error",
101
+ "message": "Failed to write session state file",
102
+ }
103
+
104
+ # Create git commit with session state
105
+ commit_success = self._create_pause_commit(session_id, conversation_summary)
106
+
107
+ # Display success message
108
+ self._display_pause_success(session_id, session_file, commit_success)
109
+
110
+ return {
111
+ "status": "success",
112
+ "session_id": session_id,
113
+ "session_file": str(session_file),
114
+ "git_commit_created": commit_success,
115
+ "message": f"Session paused: {session_id}",
116
+ }
117
+
118
+ except Exception as e:
119
+ logger.error(f"Failed to pause session: {e}")
120
+ return {"status": "error", "message": str(e)}
121
+
122
+ def _capture_git_context(self) -> Dict[str, Any]:
123
+ """Capture current git repository state.
124
+
125
+ Returns:
126
+ Dict containing git context information
127
+ """
128
+ git_context: Dict[str, Any] = {
129
+ "is_git_repo": False,
130
+ "branch": None,
131
+ "recent_commits": [],
132
+ "status": {"clean": True, "modified_files": [], "untracked_files": []},
133
+ }
134
+
135
+ try:
136
+ # Check if git repo
137
+ result = subprocess.run(
138
+ ["git", "rev-parse", "--git-dir"],
139
+ cwd=str(self.project_path),
140
+ capture_output=True,
141
+ text=True,
142
+ check=False,
143
+ )
144
+
145
+ if result.returncode != 0:
146
+ return git_context
147
+
148
+ git_context["is_git_repo"] = True
149
+
150
+ # Get current branch
151
+ result = subprocess.run(
152
+ ["git", "branch", "--show-current"],
153
+ cwd=str(self.project_path),
154
+ capture_output=True,
155
+ text=True,
156
+ check=True,
157
+ )
158
+ git_context["branch"] = result.stdout.strip()
159
+
160
+ # Get recent commits (last 10)
161
+ result = subprocess.run(
162
+ ["git", "log", "--format=%h|%an|%ai|%s", "-10"],
163
+ cwd=str(self.project_path),
164
+ capture_output=True,
165
+ text=True,
166
+ check=True,
167
+ )
168
+
169
+ commits = []
170
+ for line in result.stdout.strip().split("\n"):
171
+ if not line:
172
+ continue
173
+ parts = line.split("|", 3)
174
+ if len(parts) == 4:
175
+ sha, author, timestamp_str, message = parts
176
+ commits.append(
177
+ {
178
+ "sha": sha,
179
+ "author": author,
180
+ "timestamp": timestamp_str,
181
+ "message": message,
182
+ }
183
+ )
184
+ git_context["recent_commits"] = commits
185
+
186
+ # Get status
187
+ result = subprocess.run(
188
+ ["git", "status", "--porcelain"],
189
+ cwd=str(self.project_path),
190
+ capture_output=True,
191
+ text=True,
192
+ check=True,
193
+ )
194
+
195
+ modified_files = []
196
+ untracked_files = []
197
+
198
+ for line in result.stdout.strip().split("\n"):
199
+ if not line:
200
+ continue
201
+ status_code = line[:2]
202
+ file_path = line[3:]
203
+
204
+ if status_code.startswith("??"):
205
+ untracked_files.append(file_path)
206
+ else:
207
+ modified_files.append(file_path)
208
+
209
+ git_context["status"] = {
210
+ "clean": len(modified_files) == 0 and len(untracked_files) == 0,
211
+ "modified_files": modified_files,
212
+ "untracked_files": untracked_files,
213
+ }
214
+
215
+ except Exception as e:
216
+ logger.warning(f"Could not capture git context: {e}")
217
+
218
+ return git_context
219
+
220
+ def _get_version(self) -> str:
221
+ """Get Claude MPM version.
222
+
223
+ Returns:
224
+ Version string or "unknown"
225
+ """
226
+ try:
227
+ version_file = Path(__file__).parent.parent.parent.parent.parent / "VERSION"
228
+ if version_file.exists():
229
+ return version_file.read_text().strip()
230
+ except Exception:
231
+ pass
232
+ return "unknown"
233
+
234
+ def _get_build_number(self) -> str:
235
+ """Get Claude MPM build number.
236
+
237
+ Returns:
238
+ Build number string or "unknown"
239
+ """
240
+ try:
241
+ build_file = (
242
+ Path(__file__).parent.parent.parent.parent.parent / "BUILD_NUMBER"
243
+ )
244
+ if build_file.exists():
245
+ return build_file.read_text().strip()
246
+ except Exception:
247
+ pass
248
+ return "unknown"
249
+
250
+ def _create_pause_commit(self, session_id: str, summary: Optional[str]) -> bool:
251
+ """Create git commit with session pause information.
252
+
253
+ Args:
254
+ session_id: The session identifier
255
+ summary: Optional summary of what was being worked on
256
+
257
+ Returns:
258
+ True if commit was created successfully
259
+ """
260
+ try:
261
+ # Check if we're in a git repo
262
+ result = subprocess.run(
263
+ ["git", "rev-parse", "--git-dir"],
264
+ cwd=str(self.project_path),
265
+ capture_output=True,
266
+ text=True,
267
+ check=False,
268
+ )
269
+
270
+ if result.returncode != 0:
271
+ logger.debug("Not a git repository, skipping commit")
272
+ return False
273
+
274
+ # Add session files to git
275
+ subprocess.run(
276
+ ["git", "add", ".claude-mpm/sessions/pause/"],
277
+ cwd=str(self.project_path),
278
+ capture_output=True,
279
+ check=False,
280
+ )
281
+
282
+ # Check if there are changes to commit
283
+ result = subprocess.run(
284
+ ["git", "diff", "--cached", "--quiet"],
285
+ cwd=str(self.project_path),
286
+ check=False,
287
+ )
288
+
289
+ if result.returncode == 0:
290
+ logger.debug("No changes to commit")
291
+ return False
292
+
293
+ # Build commit message
294
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
295
+ context = summary or "Session paused"
296
+
297
+ commit_message = f"""session: pause at {timestamp}
298
+
299
+ Session ID: {session_id}
300
+ Context: {context}
301
+
302
+ 🤖👥 Generated with [Claude MPM](https://github.com/bobmatnyc/claude-mpm)
303
+
304
+ Co-Authored-By: Claude <noreply@anthropic.com>"""
305
+
306
+ # Create commit
307
+ result = subprocess.run(
308
+ ["git", "commit", "-m", commit_message],
309
+ cwd=str(self.project_path),
310
+ capture_output=True,
311
+ text=True,
312
+ check=False,
313
+ )
314
+
315
+ if result.returncode == 0:
316
+ logger.info(f"Created pause commit for session {session_id}")
317
+ return True
318
+ logger.warning(f"Could not create commit: {result.stderr}")
319
+ return False
320
+
321
+ except Exception as e:
322
+ logger.warning(f"Could not create pause commit: {e}")
323
+ return False
324
+
325
+ def _display_pause_success(
326
+ self, session_id: str, session_file: Path, commit_created: bool
327
+ ) -> None:
328
+ """Display success message for paused session.
329
+
330
+ Args:
331
+ session_id: The session identifier
332
+ session_file: Path to the session state file
333
+ commit_created: Whether git commit was created
334
+ """
335
+ console.print()
336
+ console.print(
337
+ Panel(
338
+ f"[bold green]Session Paused Successfully[/bold green]\n\n"
339
+ f"[cyan]Session ID:[/cyan] {session_id}\n"
340
+ f"[cyan]State saved:[/cyan] {session_file}\n"
341
+ f"[cyan]Git commit:[/cyan] {'✓ Created' if commit_created else '✗ Not created (no git repo or no changes)'}\n\n"
342
+ f"[dim]To resume this session later, run:[/dim]\n"
343
+ f"[yellow] claude-mpm mpm-init pause resume[/yellow]",
344
+ title="🔴 Session Pause",
345
+ border_style="green",
346
+ )
347
+ )
348
+ console.print()
349
+
350
+ def list_paused_sessions(self) -> List[Dict[str, Any]]:
351
+ """List all paused sessions.
352
+
353
+ Returns:
354
+ List of session information dicts
355
+ """
356
+ if not self.session_dir.exists():
357
+ return []
358
+
359
+ sessions = []
360
+ for session_file in sorted(self.session_dir.glob("session-*.json")):
361
+ try:
362
+ state = self.storage.read_json(session_file)
363
+ if state:
364
+ sessions.append(
365
+ {
366
+ "session_id": state.get("session_id"),
367
+ "paused_at": state.get("paused_at"),
368
+ "summary": state.get("conversation", {}).get("summary"),
369
+ "file_path": str(session_file),
370
+ }
371
+ )
372
+ except Exception as e:
373
+ logger.warning(f"Could not read session file {session_file}: {e}")
374
+
375
+ return sessions
376
+
377
+ def get_latest_session(self) -> Optional[Dict[str, Any]]:
378
+ """Get the most recent paused session.
379
+
380
+ Returns:
381
+ Session state dict or None if no sessions found
382
+ """
383
+ sessions = self.list_paused_sessions()
384
+ if not sessions:
385
+ return None
386
+
387
+ # Return the most recent (last in sorted list)
388
+ latest_file = Path(sessions[-1]["file_path"])
389
+ return self.storage.read_json(latest_file)