aline-ai 0.2.6__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.
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/METADATA +3 -1
- aline_ai-0.3.0.dist-info/RECORD +41 -0
- aline_ai-0.3.0.dist-info/entry_points.txt +3 -0
- realign/__init__.py +32 -1
- realign/cli.py +203 -19
- realign/commands/__init__.py +2 -2
- realign/commands/clean.py +149 -0
- realign/commands/config.py +1 -1
- realign/commands/export_shares.py +1785 -0
- realign/commands/hide.py +112 -24
- realign/commands/import_history.py +873 -0
- realign/commands/init.py +104 -217
- realign/commands/mirror.py +131 -0
- realign/commands/pull.py +101 -0
- realign/commands/push.py +155 -245
- realign/commands/review.py +216 -54
- realign/commands/session_utils.py +139 -4
- realign/commands/share.py +965 -0
- realign/commands/status.py +559 -0
- realign/commands/sync.py +91 -0
- realign/commands/undo.py +423 -0
- realign/commands/watcher.py +805 -0
- realign/config.py +21 -10
- realign/file_lock.py +3 -1
- realign/hash_registry.py +310 -0
- realign/hooks.py +115 -411
- realign/logging_config.py +2 -2
- realign/mcp_server.py +263 -549
- realign/mcp_watcher.py +997 -139
- realign/mirror_utils.py +322 -0
- realign/prompts/__init__.py +21 -0
- realign/prompts/presets.py +238 -0
- realign/redactor.py +168 -16
- realign/tracker/__init__.py +9 -0
- realign/tracker/git_tracker.py +1123 -0
- realign/watcher_daemon.py +115 -0
- aline_ai-0.2.6.dist-info/RECORD +0 -28
- aline_ai-0.2.6.dist-info/entry_points.txt +0 -5
- realign/commands/auto_commit.py +0 -242
- realign/commands/commit.py +0 -379
- realign/commands/search.py +0 -449
- realign/commands/show.py +0 -416
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/WHEEL +0 -0
- {aline_ai-0.2.6.dist-info → aline_ai-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.2.6.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
|