aline-ai 0.2.5__py3-none-any.whl → 0.3.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.
Files changed (45) hide show
  1. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
  2. aline_ai-0.3.0.dist-info/RECORD +41 -0
  3. aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
  4. realign/__init__.py +32 -1
  5. realign/cli.py +203 -19
  6. realign/commands/__init__.py +2 -2
  7. realign/commands/clean.py +149 -0
  8. realign/commands/config.py +1 -1
  9. realign/commands/export_shares.py +1785 -0
  10. realign/commands/hide.py +112 -24
  11. realign/commands/import_history.py +873 -0
  12. realign/commands/init.py +104 -217
  13. realign/commands/mirror.py +131 -0
  14. realign/commands/pull.py +101 -0
  15. realign/commands/push.py +155 -245
  16. realign/commands/review.py +216 -54
  17. realign/commands/session_utils.py +139 -4
  18. realign/commands/share.py +965 -0
  19. realign/commands/status.py +559 -0
  20. realign/commands/sync.py +91 -0
  21. realign/commands/undo.py +423 -0
  22. realign/commands/watcher.py +805 -0
  23. realign/config.py +21 -10
  24. realign/file_lock.py +3 -1
  25. realign/hash_registry.py +310 -0
  26. realign/hooks.py +368 -384
  27. realign/logging_config.py +2 -2
  28. realign/mcp_server.py +263 -549
  29. realign/mcp_watcher.py +999 -142
  30. realign/mirror_utils.py +322 -0
  31. realign/prompts/__init__.py +21 -0
  32. realign/prompts/presets.py +238 -0
  33. realign/redactor.py +168 -16
  34. realign/tracker/__init__.py +9 -0
  35. realign/tracker/git_tracker.py +1123 -0
  36. realign/watcher_daemon.py +115 -0
  37. aline_ai-0.2.5.dist-info/RECORD +0 -28
  38. aline_ai-0.2.5.dist-info/entry_points.txt +0 -5
  39. realign/commands/auto_commit.py +0 -231
  40. realign/commands/commit.py +0 -379
  41. realign/commands/search.py +0 -449
  42. realign/commands/show.py +0 -416
  43. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
  44. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
  45. {aline_ai-0.2.5.dist-info → aline_ai-0.3.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,805 @@
1
+ #!/usr/bin/env python3
2
+ """Aline watcher commands - Manage MCP watcher process."""
3
+
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from datetime import datetime
10
+ from typing import Dict, List, Optional
11
+
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from ..config import ReAlignConfig
16
+ from ..hooks import find_all_active_sessions
17
+ from ..logging_config import setup_logger
18
+
19
+ # Initialize logger
20
+ logger = setup_logger('realign.watcher', 'watcher.log')
21
+ console = Console()
22
+
23
+
24
+ def get_watcher_pid_file() -> Path:
25
+ """Get path to the watcher PID file."""
26
+ return Path.home() / '.aline/.logs/watcher.pid'
27
+
28
+
29
+ def detect_watcher_process() -> tuple[bool, int | None, str]:
30
+ """
31
+ Detect if watcher is running (either MCP or standalone).
32
+
33
+ Returns:
34
+ tuple: (is_running, pid, mode)
35
+ mode can be: 'mcp', 'standalone', or 'unknown'
36
+ """
37
+ # First check for standalone daemon via PID file
38
+ pid_file = get_watcher_pid_file()
39
+ if pid_file.exists():
40
+ try:
41
+ pid = int(pid_file.read_text().strip())
42
+ # Verify process is still running
43
+ try:
44
+ import os
45
+ os.kill(pid, 0) # Signal 0 just checks if process exists
46
+ return True, pid, 'standalone'
47
+ except (OSError, ProcessLookupError):
48
+ # PID file exists but process is dead - clean it up
49
+ pid_file.unlink(missing_ok=True)
50
+ except (ValueError, Exception) as e:
51
+ logger.warning(f"Failed to read PID file: {e}")
52
+
53
+ # Then check for MCP watcher process
54
+ try:
55
+ ps_output = subprocess.run(
56
+ ['ps', 'aux'],
57
+ capture_output=True,
58
+ text=True,
59
+ timeout=2
60
+ )
61
+ if ps_output.returncode == 0:
62
+ for line in ps_output.stdout.split('\n'):
63
+ if 'aline-mcp' in line:
64
+ # Extract PID (second column)
65
+ parts = line.split()
66
+ if len(parts) > 1:
67
+ try:
68
+ pid = int(parts[1])
69
+ return True, pid, 'mcp'
70
+ except ValueError:
71
+ return True, None, 'mcp'
72
+ return False, None, 'unknown'
73
+ except subprocess.TimeoutExpired:
74
+ logger.warning("Process check timed out")
75
+ return False, None, 'unknown'
76
+
77
+
78
+ def detect_all_watcher_processes() -> list[tuple[int, str]]:
79
+ """
80
+ Detect ALL running watcher processes (both standalone and MCP).
81
+
82
+ Returns:
83
+ list of tuples: [(pid, mode), ...]
84
+ mode can be: 'standalone' or 'mcp'
85
+ """
86
+ processes = []
87
+
88
+ try:
89
+ # Use ps to find all watcher_daemon.py processes
90
+ ps_output = subprocess.run(
91
+ ['ps', 'aux'],
92
+ capture_output=True,
93
+ text=True,
94
+ timeout=2
95
+ )
96
+
97
+ if ps_output.returncode == 0:
98
+ for line in ps_output.stdout.split('\n'):
99
+ # Look for watcher_daemon.py processes
100
+ if 'watcher_daemon.py' in line and 'grep' not in line:
101
+ parts = line.split()
102
+ if len(parts) > 1:
103
+ try:
104
+ pid = int(parts[1])
105
+ # Determine mode based on command line
106
+ if 'aline-mcp' in line:
107
+ processes.append((pid, 'mcp'))
108
+ else:
109
+ processes.append((pid, 'standalone'))
110
+ except ValueError:
111
+ continue
112
+
113
+ except subprocess.TimeoutExpired:
114
+ logger.warning("Process check timed out")
115
+ except Exception as e:
116
+ logger.warning(f"Failed to detect watcher processes: {e}")
117
+
118
+ return processes
119
+
120
+
121
+ def check_watcher_log_activity() -> tuple[bool, datetime | None]:
122
+ """
123
+ Check if watcher log has recent activity.
124
+
125
+ Returns:
126
+ tuple: (is_active, last_modified)
127
+ """
128
+ log_path = Path.home() / '.aline/.logs/mcp_watcher.log'
129
+ if log_path.exists():
130
+ try:
131
+ last_modified = datetime.fromtimestamp(log_path.stat().st_mtime)
132
+ seconds_since_modified = (datetime.now() - last_modified).total_seconds()
133
+ is_active = seconds_since_modified < 300 # 5 mins
134
+ return is_active, last_modified
135
+ except Exception as e:
136
+ logger.warning(f"Failed to check log timestamp: {e}")
137
+ return False, None
138
+ return False, None
139
+
140
+
141
+ def get_watched_projects() -> list[Path]:
142
+ """
143
+ Get list of projects being watched by checking ~/.aline directory.
144
+
145
+ Returns:
146
+ List of project paths that have Aline initialized
147
+ """
148
+ aline_dir = Path.home() / '.aline'
149
+ if not aline_dir.exists():
150
+ return []
151
+
152
+ watched = []
153
+ try:
154
+ for project_dir in aline_dir.iterdir():
155
+ if project_dir.is_dir() and project_dir.name not in ['.logs', '.cache']:
156
+ # Skip test/temporary directories
157
+ if project_dir.name.startswith(('tmp', 'test_')):
158
+ continue
159
+ # Check if it has .git (shadow git repo)
160
+ if (project_dir / '.git').exists():
161
+ watched.append(project_dir)
162
+ except Exception as e:
163
+ logger.warning(f"Failed to scan watched projects: {e}")
164
+
165
+ return watched
166
+
167
+
168
+ def extract_project_name_from_session(session_file: Path) -> str:
169
+ """
170
+ Extract project name from session file path.
171
+
172
+ Supports:
173
+ - Claude Code format: ~/.claude/projects/-Users-foo-Projects-MyApp/abc.jsonl → MyApp
174
+ - .aline format: ~/.aline/MyProject/sessions/abc.jsonl → MyProject
175
+
176
+ Args:
177
+ session_file: Path to session file
178
+
179
+ Returns:
180
+ Project name, or "unknown" if cannot determine
181
+ """
182
+ try:
183
+ # Method 1: Claude Code format
184
+ if '.claude/projects/' in str(session_file):
185
+ project_dir = session_file.parent.name
186
+ if project_dir.startswith('-'):
187
+ # Decode: -Users-foo-Projects-MyApp → MyApp
188
+ parts = project_dir[1:].split('-')
189
+ return parts[-1] if parts else "unknown"
190
+
191
+ # Method 2: .aline format
192
+ if '.aline/' in str(session_file):
193
+ # Find the project directory (parent of 'sessions')
194
+ path_parts = session_file.parts
195
+ try:
196
+ aline_idx = path_parts.index('.aline')
197
+ if aline_idx + 1 < len(path_parts):
198
+ return path_parts[aline_idx + 1]
199
+ except ValueError:
200
+ pass
201
+
202
+ return "unknown"
203
+ except Exception as e:
204
+ logger.debug(f"Error extracting project name from {session_file}: {e}")
205
+ return "unknown"
206
+
207
+
208
+ def _detect_session_type(session_file: Path) -> str:
209
+ """
210
+ Detect the type of session file.
211
+
212
+ Returns:
213
+ "claude" for Claude Code sessions
214
+ "codex" for Codex/GPT sessions
215
+ "unknown" if cannot determine
216
+ """
217
+ try:
218
+ with open(session_file, 'r', encoding='utf-8') as f:
219
+ for i, line in enumerate(f):
220
+ if i >= 20:
221
+ break
222
+ line = line.strip()
223
+ if not line:
224
+ continue
225
+ try:
226
+ data = json.loads(line)
227
+ if data.get("type") in ("assistant", "user") and "message" in data:
228
+ return "claude"
229
+ if data.get("type") == "session_meta":
230
+ payload = data.get("payload", {})
231
+ if "codex" in payload.get("originator", "").lower():
232
+ return "codex"
233
+ if data.get("type") == "response_item":
234
+ payload = data.get("payload", {})
235
+ if "message" not in data and "role" in payload:
236
+ return "codex"
237
+ except json.JSONDecodeError:
238
+ continue
239
+ return "unknown"
240
+ except Exception as e:
241
+ logger.debug(f"Error detecting session type: {e}")
242
+ return "unknown"
243
+
244
+
245
+ def _count_complete_turns(session_file: Path) -> int:
246
+ """
247
+ Count complete dialogue turns in a session file.
248
+
249
+ Returns:
250
+ Number of complete turns
251
+ """
252
+ session_type = _detect_session_type(session_file)
253
+
254
+ if session_type == "claude":
255
+ return _count_claude_turns(session_file)
256
+ elif session_type == "codex":
257
+ return _count_codex_turns(session_file)
258
+ else:
259
+ return 0
260
+
261
+
262
+ def _count_claude_turns(session_file: Path) -> int:
263
+ """Count complete dialogue turns for Claude Code sessions."""
264
+ user_message_ids = set()
265
+ try:
266
+ with open(session_file, 'r', encoding='utf-8') as f:
267
+ for line in f:
268
+ line = line.strip()
269
+ if not line:
270
+ continue
271
+ try:
272
+ data = json.loads(line)
273
+ msg_type = data.get("type")
274
+
275
+ if msg_type == "user":
276
+ message = data.get("message", {})
277
+ content = message.get("content", [])
278
+
279
+ is_tool_result = False
280
+ if isinstance(content, list):
281
+ for item in content:
282
+ if isinstance(item, dict) and item.get("type") == "tool_result":
283
+ is_tool_result = True
284
+ break
285
+
286
+ if not is_tool_result:
287
+ uuid = data.get("uuid")
288
+ if uuid:
289
+ user_message_ids.add(uuid)
290
+ except json.JSONDecodeError:
291
+ continue
292
+ return len(user_message_ids)
293
+ except Exception as e:
294
+ logger.debug(f"Error counting Claude turns: {e}")
295
+ return 0
296
+
297
+
298
+ def _count_codex_turns(session_file: Path) -> int:
299
+ """Count complete dialogue turns for Codex sessions."""
300
+ count = 0
301
+ try:
302
+ with open(session_file, 'r', encoding='utf-8') as f:
303
+ for line in f:
304
+ line = line.strip()
305
+ if not line:
306
+ continue
307
+ try:
308
+ data = json.loads(line)
309
+ if data.get("type") == "event_msg":
310
+ payload = data.get("payload", {})
311
+ if payload.get("type") == "token_count":
312
+ count += 1
313
+ except json.JSONDecodeError:
314
+ continue
315
+ except Exception as e:
316
+ logger.debug(f"Error counting Codex turns: {e}")
317
+ return count
318
+
319
+
320
+ def get_session_details(session_file: Path, idle_timeout: float = 300.0) -> Dict:
321
+ """
322
+ Get detailed information about a session file.
323
+
324
+ Args:
325
+ session_file: Path to session file
326
+ idle_timeout: Idle timeout threshold in seconds
327
+
328
+ Returns:
329
+ dict with session details including:
330
+ - name: session filename
331
+ - path: session file path
332
+ - project_name: project name extracted from path
333
+ - type: claude/codex/unknown
334
+ - turns: number of complete turns
335
+ - mtime: last modified time
336
+ - idle_seconds: seconds since last modification
337
+ - is_idle: whether session exceeds idle timeout
338
+ - size_kb: file size in KB
339
+ """
340
+ try:
341
+ stat = session_file.stat()
342
+ mtime = datetime.fromtimestamp(stat.st_mtime)
343
+ current_time = time.time()
344
+ idle_seconds = current_time - stat.st_mtime
345
+
346
+ return {
347
+ "name": session_file.name,
348
+ "path": session_file,
349
+ "project_name": extract_project_name_from_session(session_file),
350
+ "type": _detect_session_type(session_file),
351
+ "turns": _count_complete_turns(session_file),
352
+ "mtime": mtime,
353
+ "idle_seconds": idle_seconds,
354
+ "is_idle": idle_seconds >= idle_timeout,
355
+ "size_kb": stat.st_size / 1024
356
+ }
357
+ except Exception as e:
358
+ logger.debug(f"Error getting session details for {session_file}: {e}")
359
+ return {
360
+ "name": session_file.name,
361
+ "path": session_file,
362
+ "project_name": "unknown",
363
+ "type": "error",
364
+ "turns": 0,
365
+ "mtime": None,
366
+ "idle_seconds": 0,
367
+ "is_idle": False,
368
+ "size_kb": 0
369
+ }
370
+
371
+
372
+ def get_all_tracked_sessions() -> List[Dict]:
373
+ """
374
+ Get detailed information for all active sessions being tracked.
375
+
376
+ Returns:
377
+ List of session detail dictionaries
378
+ """
379
+ try:
380
+ config = ReAlignConfig.load()
381
+
382
+ # Find all active sessions across ALL projects (multi-project mode)
383
+ all_sessions = find_all_active_sessions(config, project_path=None)
384
+
385
+ # Get details for each session
386
+ session_details = []
387
+ for session_file in all_sessions:
388
+ if session_file.exists():
389
+ details = get_session_details(session_file)
390
+ session_details.append(details)
391
+
392
+ # Sort by mtime (most recent first)
393
+ session_details.sort(key=lambda x: x["mtime"] if x["mtime"] else datetime.min, reverse=True)
394
+
395
+ return session_details
396
+ except Exception as e:
397
+ logger.error(f"Error getting tracked sessions: {e}")
398
+ return []
399
+
400
+
401
+ def watcher_status_command(verbose: bool = False) -> int:
402
+ """
403
+ Display watcher status.
404
+
405
+ Args:
406
+ verbose: Show detailed session tracking information
407
+
408
+ Returns:
409
+ int: Exit code (0 = success, 1 = error)
410
+ """
411
+ try:
412
+ config = ReAlignConfig.load()
413
+
414
+ # Check process
415
+ is_running, pid, mode = detect_watcher_process()
416
+
417
+ # Check log activity
418
+ is_log_active, last_activity = check_watcher_log_activity()
419
+
420
+ # Determine status
421
+ if is_running and is_log_active:
422
+ status = "Running"
423
+ color = "green"
424
+ symbol = "✓"
425
+ elif not is_running:
426
+ status = "Stopped"
427
+ color = "red"
428
+ symbol = "✗"
429
+ else:
430
+ status = "Inactive"
431
+ color = "yellow"
432
+ symbol = "✗"
433
+
434
+ # Display status
435
+ console.print(f"\n[bold cyan]Watcher Status[/bold cyan]")
436
+ console.print(f" Status: [{color}]{status} {symbol}[/{color}]")
437
+
438
+ if pid:
439
+ console.print(f" PID: {pid}")
440
+
441
+ # Show mode
442
+ if mode == 'standalone':
443
+ console.print(f" Mode: [cyan]Standalone[/cyan]")
444
+ elif mode == 'mcp':
445
+ console.print(f" Mode: [cyan]MCP[/cyan]")
446
+
447
+ if last_activity:
448
+ # Format time
449
+ absolute = last_activity.strftime('%Y-%m-%d %H:%M:%S')
450
+ diff = (datetime.now() - last_activity).total_seconds()
451
+
452
+ if diff < 60:
453
+ relative = "just now"
454
+ elif diff < 3600:
455
+ mins = int(diff // 60)
456
+ relative = f"{mins} min{'s' if mins != 1 else ''} ago"
457
+ else:
458
+ hours = int(diff // 3600)
459
+ relative = f"{hours} hour{'s' if hours != 1 else ''} ago"
460
+
461
+ console.print(f" Last Activity: {absolute} ({relative})")
462
+
463
+ # Show tracked sessions (if verbose or if there are active sessions)
464
+ if verbose:
465
+ console.print(f"\n[bold cyan]Tracked Sessions (last 24h)[/bold cyan]")
466
+ session_details = get_all_tracked_sessions()
467
+
468
+ if session_details:
469
+ # Filter sessions
470
+ current_time = time.time()
471
+ session_details = [
472
+ s for s in session_details
473
+ # Filter 1: Remove unknown/empty sessions
474
+ if not (s["type"] == "unknown" and s["turns"] == 0 and s["size_kb"] < 0.1)
475
+ # Filter 2: Only show sessions modified within last 24 hours
476
+ and (current_time - s["mtime"].timestamp() if s["mtime"] else 0) <= 86400
477
+ ]
478
+
479
+ if session_details:
480
+ # Create a rich table
481
+ table = Table(show_header=True, header_style="bold magenta")
482
+ table.add_column("Session", style="cyan", no_wrap=False, max_width=35)
483
+ table.add_column("Project", style="dim", width=15)
484
+ table.add_column("Type", justify="center", style="dim", width=8)
485
+ table.add_column("Turns", justify="center", style="yellow", width=6)
486
+ table.add_column("Status", justify="center", width=10)
487
+ table.add_column("Last Modified", style="dim", width=20)
488
+ table.add_column("Size", justify="right", style="dim", width=10)
489
+
490
+ for session in session_details:
491
+ # Format status (idle + commit status)
492
+ idle_sec = session["idle_seconds"]
493
+ turns = session["turns"]
494
+
495
+ # Determine color
496
+ if idle_sec < 60:
497
+ idle_color = "green"
498
+ elif idle_sec < 300:
499
+ idle_color = "yellow"
500
+ else:
501
+ idle_color = "red"
502
+
503
+ # Format idle time
504
+ if idle_sec < 60:
505
+ idle_str = f"{int(idle_sec)}s"
506
+ else:
507
+ idle_str = f"{int(idle_sec // 60)}m"
508
+
509
+ # Determine commit status symbol
510
+ if turns == 0:
511
+ status_symbol = "●" # Empty
512
+ elif idle_sec < 10:
513
+ status_symbol = "⏳" # Processing
514
+ else:
515
+ status_symbol = "✓" # Committed
516
+
517
+ status_display = f"[{idle_color}]{idle_str} {status_symbol}[/{idle_color}]"
518
+
519
+ # Format mtime
520
+ if session["mtime"]:
521
+ mtime_str = session["mtime"].strftime("%H:%M:%S")
522
+ else:
523
+ mtime_str = "N/A"
524
+
525
+ # Format size
526
+ size_kb = session["size_kb"]
527
+ if size_kb < 1:
528
+ size_str = f"{int(size_kb * 1024)}B"
529
+ elif size_kb < 1024:
530
+ size_str = f"{size_kb:.1f}KB"
531
+ else:
532
+ size_str = f"{size_kb / 1024:.1f}MB"
533
+
534
+ # Truncate session name if too long
535
+ session_name = session["name"]
536
+ if len(session_name) > 32:
537
+ session_name = session_name[:29] + "..."
538
+
539
+ table.add_row(
540
+ session_name,
541
+ session["project_name"],
542
+ session["type"],
543
+ str(session["turns"]),
544
+ status_display,
545
+ mtime_str,
546
+ size_str
547
+ )
548
+
549
+ console.print(table)
550
+ console.print(f"\n [dim]Total: {len(session_details)} active session(s)[/dim]")
551
+ console.print(f" [dim]Status: ✓=Committed ⏳=Processing ●=Empty[/dim]")
552
+ else:
553
+ console.print(f" [dim]No active sessions found[/dim]")
554
+
555
+ # Show watched projects
556
+ watched_projects = get_watched_projects()
557
+ if watched_projects:
558
+ console.print(f"\n[bold cyan]Watched Projects[/bold cyan]")
559
+
560
+ # Create table
561
+ projects_table = Table(show_header=True, header_style="bold magenta")
562
+ projects_table.add_column("Project", style="cyan", width=20)
563
+ projects_table.add_column("Sessions", justify="center", style="yellow", width=10)
564
+ projects_table.add_column("Last Commit", style="dim", width=20)
565
+
566
+ for proj in watched_projects:
567
+ try:
568
+ project_name = proj.name
569
+
570
+ # Count sessions
571
+ sessions_dir = proj / "sessions"
572
+ session_count = 0
573
+ if sessions_dir.exists():
574
+ session_count = len(list(sessions_dir.glob("*.jsonl")))
575
+
576
+ # Get last commit time
577
+ last_commit = ""
578
+ git_dir = proj / ".git"
579
+ if git_dir.exists():
580
+ try:
581
+ result = subprocess.run(
582
+ ["git", "log", "-1", "--format=%cr"],
583
+ cwd=proj,
584
+ capture_output=True,
585
+ text=True,
586
+ check=False
587
+ )
588
+ if result.returncode == 0 and result.stdout.strip():
589
+ last_commit = result.stdout.strip()
590
+ except Exception:
591
+ pass
592
+
593
+ # Add row to table
594
+ projects_table.add_row(
595
+ project_name,
596
+ str(session_count) if session_count > 0 else "-",
597
+ last_commit if last_commit else "-"
598
+ )
599
+
600
+ except Exception as e:
601
+ logger.debug(f"Error reading project info: {e}")
602
+ continue
603
+
604
+ console.print(projects_table)
605
+ console.print(f"\n [dim]Total: {len(watched_projects)} watched project(s)[/dim]")
606
+ else:
607
+ console.print(f"\n[dim]No projects being watched yet[/dim]")
608
+ console.print(f"[dim]Run 'aline init' in a project directory to start tracking[/dim]")
609
+
610
+ # Suggestions
611
+ if status == "Stopped":
612
+ console.print(f"\n [dim]Run 'aline watcher start' to start the watcher[/dim]")
613
+ elif status == "Inactive":
614
+ console.print(f"\n [dim]Suggestion: Restart watcher or check logs[/dim]")
615
+
616
+ if not verbose:
617
+ console.print(f"\n [dim]Use 'aline watcher status -v' to see detailed session tracking[/dim]")
618
+
619
+ console.print()
620
+ return 0
621
+
622
+ except Exception as e:
623
+ logger.error(f"Error in watcher status: {e}", exc_info=True)
624
+ console.print(f"[red]Error: {e}[/red]")
625
+ return 1
626
+
627
+
628
+ def watcher_start_command() -> int:
629
+ """
630
+ Start the watcher in standalone mode.
631
+
632
+ Launches a background daemon process that monitors session files
633
+ and auto-commits changes.
634
+
635
+ Returns:
636
+ int: Exit code (0 = success, 1 = error)
637
+ """
638
+ try:
639
+ # Check if already running
640
+ is_running, pid, mode = detect_watcher_process()
641
+
642
+ if is_running:
643
+ console.print(f"[yellow]Watcher is already running (PID: {pid}, mode: {mode})[/yellow]")
644
+ console.print(f"[dim]Use 'aline watcher stop' to stop it first[/dim]")
645
+ return 0
646
+
647
+ console.print(f"[cyan]Starting standalone watcher daemon...[/cyan]")
648
+
649
+ # Launch the daemon as a background process
650
+ import os
651
+ import importlib.util
652
+
653
+ # Get the path to the daemon script
654
+ spec = importlib.util.find_spec("realign.watcher_daemon")
655
+ if not spec or not spec.origin:
656
+ console.print(f"[red]✗ Could not find watcher daemon module[/red]")
657
+ return 1
658
+
659
+ daemon_script = spec.origin
660
+
661
+ # Launch daemon using python with nohup-like behavior
662
+ # Using start_new_session=True to detach from terminal
663
+ log_dir = Path.home() / '.aline/.logs'
664
+ log_dir.mkdir(parents=True, exist_ok=True)
665
+
666
+ stdout_log = log_dir / 'watcher_stdout.log'
667
+ stderr_log = log_dir / 'watcher_stderr.log'
668
+
669
+ with open(stdout_log, 'a') as stdout_f, open(stderr_log, 'a') as stderr_f:
670
+ process = subprocess.Popen(
671
+ [sys.executable, daemon_script],
672
+ stdout=stdout_f,
673
+ stderr=stderr_f,
674
+ start_new_session=True,
675
+ cwd=Path.cwd()
676
+ )
677
+
678
+ # Give it a moment to start
679
+ import time
680
+ time.sleep(1)
681
+
682
+ # Verify it started
683
+ is_running, pid, mode = detect_watcher_process()
684
+
685
+ if is_running:
686
+ console.print(f"[green]✓ Watcher started successfully (PID: {pid})[/green]")
687
+ console.print(f"[dim]Logs: {log_dir}/watcher_*.log[/dim]")
688
+ return 0
689
+ else:
690
+ console.print(f"[red]✗ Failed to start watcher[/red]")
691
+ console.print(f"[dim]Check logs: {stderr_log}[/dim]")
692
+ return 1
693
+
694
+ except Exception as e:
695
+ logger.error(f"Error in watcher start: {e}", exc_info=True)
696
+ console.print(f"[red]Error: {e}[/red]")
697
+ return 1
698
+
699
+
700
+ def watcher_stop_command() -> int:
701
+ """
702
+ Stop ALL watcher processes (both standalone and MCP modes).
703
+
704
+ Returns:
705
+ int: Exit code (0 = success, 1 = error)
706
+ """
707
+ import time
708
+
709
+ try:
710
+ # Detect ALL running watcher processes
711
+ all_processes = detect_all_watcher_processes()
712
+
713
+ if not all_processes:
714
+ console.print(f"[yellow]No watcher processes found[/yellow]")
715
+ console.print(f"[dim]Use 'aline watcher start' to start it[/dim]")
716
+ return 1
717
+
718
+ # Display all processes that will be stopped
719
+ if len(all_processes) == 1:
720
+ pid, mode = all_processes[0]
721
+ console.print(f"[cyan]Stopping watcher (PID: {pid}, mode: {mode})...[/cyan]")
722
+ else:
723
+ console.print(f"[cyan]Found {len(all_processes)} watcher processes, stopping all...[/cyan]")
724
+ for pid, mode in all_processes:
725
+ console.print(f" • PID: {pid} (mode: {mode})")
726
+
727
+ # Send SIGTERM to all processes
728
+ failed_pids = []
729
+ for pid, mode in all_processes:
730
+ try:
731
+ subprocess.run(
732
+ ['kill', str(pid)],
733
+ check=True,
734
+ timeout=2
735
+ )
736
+ except subprocess.CalledProcessError:
737
+ failed_pids.append((pid, mode))
738
+
739
+ # Wait a moment for graceful shutdown
740
+ time.sleep(1)
741
+
742
+ # Check if any processes are still running
743
+ still_running = detect_all_watcher_processes()
744
+
745
+ if still_running:
746
+ # Force kill remaining processes
747
+ console.print(f"[yellow]{len(still_running)} process(es) still running, forcing stop...[/yellow]")
748
+ for pid, mode in still_running:
749
+ try:
750
+ subprocess.run(
751
+ ['kill', '-9', str(pid)],
752
+ check=True,
753
+ timeout=2
754
+ )
755
+ except subprocess.CalledProcessError as e:
756
+ console.print(f"[red]✗ Failed to force-stop PID {pid}: {e}[/red]")
757
+ failed_pids.append((pid, mode))
758
+
759
+ # Clean up PID file
760
+ get_watcher_pid_file().unlink(missing_ok=True)
761
+
762
+ # Final verification
763
+ time.sleep(0.5)
764
+ final_check = detect_all_watcher_processes()
765
+
766
+ if not final_check:
767
+ if len(all_processes) == 1:
768
+ console.print(f"[green]✓ Watcher stopped successfully[/green]")
769
+ else:
770
+ console.print(f"[green]✓ All {len(all_processes)} watcher processes stopped successfully[/green]")
771
+ return 0
772
+ else:
773
+ console.print(f"[red]✗ Failed to stop {len(final_check)} process(es)[/red]")
774
+ for pid, mode in final_check:
775
+ console.print(f" • PID {pid} ({mode}) is still running")
776
+ return 1
777
+
778
+ except Exception as e:
779
+ logger.error(f"Error stopping watcher: {e}", exc_info=True)
780
+ console.print(f"[red]Error: {e}[/red]")
781
+ return 1
782
+
783
+
784
+ def watcher_command(
785
+ action: str = "status",
786
+ ) -> int:
787
+ """
788
+ Main watcher command dispatcher.
789
+
790
+ Args:
791
+ action: Action to perform (status, start, stop)
792
+
793
+ Returns:
794
+ int: Exit code
795
+ """
796
+ if action == "status":
797
+ return watcher_status_command()
798
+ elif action == "start":
799
+ return watcher_start_command()
800
+ elif action == "stop":
801
+ return watcher_stop_command()
802
+ else:
803
+ console.print(f"[red]Unknown action: {action}[/red]")
804
+ console.print(f"[dim]Available actions: status, start, stop[/dim]")
805
+ return 1