Skip to content

Commit 19c3a0b

Browse files
feat: add Claude session awareness to Amplifier
- Create amplifier.claude module for Claude Code integrations - Implement SessionAwareness for tracking multiple concurrent sessions - Add CLI commands: status, track, broadcast, activity - Include comprehensive test suite with 13 passing tests - Store session data in .data/session_awareness/ - Auto-cleanup stale sessions after 5 minutes - Support activity logging with automatic trimming - Follow Amplifier's ruthless simplicity philosophy - File-based JSON storage, no database complexity - Fail silently to never disrupt workflows 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]>
1 parent 317feca commit 19c3a0b

File tree

6 files changed

+739
-0
lines changed

6 files changed

+739
-0
lines changed

amplifier/claude/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""
2+
Claude integration module for Amplifier.
3+
4+
Provides session awareness and coordination capabilities for Claude Code.
5+
"""
6+
7+
from .session_awareness import SessionActivity
8+
from .session_awareness import SessionAwareness
9+
10+
__all__ = ["SessionAwareness", "SessionActivity"]

amplifier/claude/cli.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""
2+
CLI commands for Claude integration features.
3+
"""
4+
5+
from datetime import UTC
6+
from datetime import datetime
7+
8+
import click
9+
10+
from amplifier.claude.session_awareness import SessionAwareness
11+
12+
13+
@click.group("claude")
14+
def claude_group():
15+
"""Claude Code integration features."""
16+
pass
17+
18+
19+
@claude_group.command("status")
20+
def session_status():
21+
"""Show status of active Claude sessions."""
22+
sa = SessionAwareness()
23+
status = sa.get_status()
24+
25+
click.echo("\n🔄 Claude Session Awareness Status")
26+
click.echo("=" * 40)
27+
click.echo(f"Current Session: {status['current_session']}")
28+
click.echo(f"Active Sessions: {status['active_sessions']}")
29+
30+
if status["sessions"]:
31+
click.echo("\n📊 Active Sessions:")
32+
for session in status["sessions"]:
33+
click.echo(
34+
f" • {session['id']} (PID: {session['pid']}) "
35+
f"- {session['duration_minutes']}min "
36+
f"- Last: {session['last_activity'] or 'No activity'}"
37+
)
38+
39+
if status["recent_activity"]:
40+
click.echo("\n📝 Recent Activity:")
41+
for activity in status["recent_activity"][:5]:
42+
click.echo(f" • [{activity['session']}] {activity['action']} ({activity['ago_seconds']}s ago)")
43+
if activity["details"]:
44+
click.echo(f" → {activity['details']}")
45+
46+
47+
@claude_group.command("track")
48+
@click.argument("action")
49+
@click.option("--details", "-d", help="Additional details about the action")
50+
def track_activity(action: str, details: str | None):
51+
"""Track an activity for the current session.
52+
53+
Example:
54+
amplifier claude track "Working on feature X" -d "Adding session awareness"
55+
"""
56+
sa = SessionAwareness()
57+
sa.register_activity(action, details)
58+
click.echo(f"✅ Tracked: {action}")
59+
60+
61+
@claude_group.command("broadcast")
62+
@click.argument("message")
63+
def broadcast_message(message: str):
64+
"""Broadcast a message to all active sessions.
65+
66+
Example:
67+
amplifier claude broadcast "Starting deployment - please pause edits"
68+
"""
69+
sa = SessionAwareness()
70+
sa.broadcast_message(message)
71+
click.echo(f"📢 Broadcast sent: {message}")
72+
73+
74+
@claude_group.command("activity")
75+
@click.option("--limit", "-n", default=20, help="Number of activities to show")
76+
def show_activity(limit: int):
77+
"""Show recent activity across all sessions."""
78+
sa = SessionAwareness()
79+
activities = sa.get_recent_activity(limit)
80+
81+
if not activities:
82+
click.echo("No recent activity found.")
83+
return
84+
85+
click.echo("\n📜 Recent Activity Log:")
86+
click.echo("=" * 40)
87+
88+
for activity in activities:
89+
timestamp = datetime.fromtimestamp(activity.timestamp, UTC).strftime("%H:%M:%S")
90+
click.echo(f"[{timestamp}] {activity.session_id}: {activity.action}")
91+
if activity.details:
92+
click.echo(f" → {activity.details}")
93+
94+
95+
# Register the command group
96+
def register_commands(cli):
97+
"""Register Claude commands with the main CLI."""
98+
cli.add_command(claude_group)
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""
2+
Session awareness for Claude Code sessions.
3+
4+
Enables multiple Claude sessions to be aware of each other's activity
5+
in the same project directory.
6+
"""
7+
8+
import json
9+
import logging
10+
import os
11+
import time
12+
from dataclasses import asdict
13+
from dataclasses import dataclass
14+
from dataclasses import field
15+
from pathlib import Path
16+
from typing import Any
17+
18+
logger = logging.getLogger(__name__)
19+
20+
# Configuration
21+
STALE_THRESHOLD_SECONDS = 300 # 5 minutes
22+
MAX_ACTIVITY_LOG_SIZE = 1000 # Keep last 1000 activities
23+
24+
25+
@dataclass
26+
class SessionActivity:
27+
"""Represents a single activity from a Claude session."""
28+
29+
session_id: str
30+
timestamp: float
31+
action: str
32+
details: str | None = None
33+
34+
35+
@dataclass
36+
class SessionInfo:
37+
"""Information about an active Claude session."""
38+
39+
session_id: str
40+
pid: int
41+
started: float
42+
last_seen: float
43+
activities: list[SessionActivity] = field(default_factory=list)
44+
45+
@property
46+
def is_stale(self) -> bool:
47+
"""Check if session hasn't been seen recently."""
48+
return time.time() - self.last_seen > STALE_THRESHOLD_SECONDS
49+
50+
51+
class SessionAwareness:
52+
"""Manages awareness of multiple Claude Code sessions."""
53+
54+
def __init__(self, project_root: Path | None = None):
55+
"""Initialize session awareness.
56+
57+
Args:
58+
project_root: Root directory for the project. Defaults to current directory.
59+
"""
60+
self.project_root = project_root or Path.cwd()
61+
self.data_dir = self.project_root / ".data" / "session_awareness"
62+
self.sessions_file = self.data_dir / "sessions.json"
63+
self.activity_log = self.data_dir / "activity.jsonl"
64+
65+
# Create data directory if needed
66+
self.data_dir.mkdir(parents=True, exist_ok=True)
67+
68+
# Get session ID from environment or generate one
69+
self.session_id = os.environ.get("CLAUDE_SESSION_ID", f"session-{os.getpid()}")
70+
self.pid = os.getpid()
71+
72+
def _load_sessions(self) -> dict[str, SessionInfo]:
73+
"""Load active sessions from disk."""
74+
if not self.sessions_file.exists():
75+
return {}
76+
77+
try:
78+
with open(self.sessions_file) as f:
79+
data = json.load(f)
80+
sessions = {}
81+
for sid, info in data.items():
82+
# Convert activity dicts to SessionActivity objects
83+
activities = [
84+
SessionActivity(**act) if isinstance(act, dict) else act for act in info.get("activities", [])
85+
]
86+
sessions[sid] = SessionInfo(
87+
session_id=info["session_id"],
88+
pid=info["pid"],
89+
started=info["started"],
90+
last_seen=info["last_seen"],
91+
activities=activities,
92+
)
93+
return sessions
94+
except (json.JSONDecodeError, KeyError) as e:
95+
logger.warning(f"Failed to load sessions: {e}")
96+
return {}
97+
98+
def _save_sessions(self, sessions: dict[str, SessionInfo]) -> None:
99+
"""Save active sessions to disk."""
100+
try:
101+
data = {sid: asdict(info) for sid, info in sessions.items()}
102+
with open(self.sessions_file, "w") as f:
103+
json.dump(data, f, indent=2)
104+
except Exception as e:
105+
logger.error(f"Failed to save sessions: {e}")
106+
107+
def _log_activity(self, activity: SessionActivity) -> None:
108+
"""Append activity to the activity log."""
109+
try:
110+
with open(self.activity_log, "a") as f:
111+
f.write(json.dumps(asdict(activity)) + "\n")
112+
except Exception as e:
113+
logger.error(f"Failed to log activity: {e}")
114+
115+
def _trim_activity_log(self) -> None:
116+
"""Keep only the last MAX_ACTIVITY_LOG_SIZE entries."""
117+
if not self.activity_log.exists():
118+
return
119+
120+
try:
121+
with open(self.activity_log) as f:
122+
lines = f.readlines()
123+
124+
if len(lines) > MAX_ACTIVITY_LOG_SIZE:
125+
with open(self.activity_log, "w") as f:
126+
f.writelines(lines[-MAX_ACTIVITY_LOG_SIZE:])
127+
except Exception as e:
128+
logger.error(f"Failed to trim activity log: {e}")
129+
130+
def register_activity(self, action: str, details: str | None = None) -> None:
131+
"""Register an activity for the current session.
132+
133+
Args:
134+
action: The action being performed (e.g., "Edit", "Read", "Test")
135+
details: Optional details about the action
136+
"""
137+
sessions = self._load_sessions()
138+
139+
# Clean up stale sessions
140+
active_sessions = {sid: info for sid, info in sessions.items() if not info.is_stale}
141+
142+
# Update current session
143+
activity = SessionActivity(session_id=self.session_id, timestamp=time.time(), action=action, details=details)
144+
145+
if self.session_id not in active_sessions:
146+
active_sessions[self.session_id] = SessionInfo(
147+
session_id=self.session_id, pid=self.pid, started=time.time(), last_seen=time.time(), activities=[]
148+
)
149+
150+
session = active_sessions[self.session_id]
151+
session.last_seen = time.time()
152+
session.activities.append(activity)
153+
154+
# Keep only recent activities per session
155+
if len(session.activities) > 10:
156+
session.activities = session.activities[-10:]
157+
158+
# Save and log
159+
self._save_sessions(active_sessions)
160+
self._log_activity(activity)
161+
self._trim_activity_log()
162+
163+
logger.debug(f"Session {self.session_id}: {action}")
164+
165+
def get_active_sessions(self) -> list[SessionInfo]:
166+
"""Get list of currently active sessions."""
167+
sessions = self._load_sessions()
168+
return [info for info in sessions.values() if not info.is_stale]
169+
170+
def get_recent_activity(self, limit: int = 20) -> list[SessionActivity]:
171+
"""Get recent activity across all sessions.
172+
173+
Args:
174+
limit: Maximum number of activities to return
175+
176+
Returns:
177+
List of recent activities, newest first
178+
"""
179+
if not self.activity_log.exists():
180+
return []
181+
182+
activities = []
183+
try:
184+
with open(self.activity_log) as f:
185+
for line in f:
186+
if line.strip():
187+
activities.append(SessionActivity(**json.loads(line)))
188+
except Exception as e:
189+
logger.error(f"Failed to read activity log: {e}")
190+
191+
# Sort by timestamp descending and return limited results
192+
activities.sort(key=lambda a: a.timestamp, reverse=True)
193+
return activities[:limit]
194+
195+
def get_status(self) -> dict[str, Any]:
196+
"""Get comprehensive status of session awareness.
197+
198+
Returns:
199+
Dictionary with status information
200+
"""
201+
active_sessions = self.get_active_sessions()
202+
recent_activity = self.get_recent_activity(10)
203+
204+
return {
205+
"current_session": self.session_id,
206+
"active_sessions": len(active_sessions),
207+
"sessions": [
208+
{
209+
"id": s.session_id,
210+
"pid": s.pid,
211+
"duration_minutes": round((time.time() - s.started) / 60, 1),
212+
"last_activity": (s.activities[-1].action if s.activities else None),
213+
}
214+
for s in active_sessions
215+
],
216+
"recent_activity": [
217+
{
218+
"session": a.session_id,
219+
"action": a.action,
220+
"ago_seconds": round(time.time() - a.timestamp),
221+
"details": a.details,
222+
}
223+
for a in recent_activity
224+
],
225+
}
226+
227+
def broadcast_message(self, message: str) -> None:
228+
"""Broadcast a message to all active sessions.
229+
230+
Args:
231+
message: Message to broadcast
232+
"""
233+
# For now, just log it as an activity
234+
# Future: Could write to a messages file that other sessions poll
235+
self.register_activity("Broadcast", message)
236+
logger.info(f"Broadcasting: {message}")

0 commit comments

Comments
 (0)