claude-team-mcp 0.8.0__py3-none-any.whl → 0.9.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.
claude_team_mcp/config.py CHANGED
@@ -53,10 +53,11 @@ class TerminalConfig:
53
53
 
54
54
  @dataclass
55
55
  class EventsConfig:
56
- """Event log rotation configuration."""
56
+ """Event log rotation and polling configuration."""
57
57
 
58
58
  max_size_mb: int = 1
59
59
  recent_hours: int = 24
60
+ stale_threshold_minutes: int = 10
60
61
 
61
62
 
62
63
  @dataclass
@@ -230,9 +231,11 @@ def _parse_terminal(value: object) -> TerminalConfig:
230
231
 
231
232
 
232
233
  def _parse_events(value: object) -> EventsConfig:
233
- # Parse event log rotation configuration.
234
+ # Parse event log rotation and polling configuration.
234
235
  data = _ensure_dict(value, "events")
235
- _validate_keys(data, {"max_size_mb", "recent_hours"}, "events")
236
+ _validate_keys(
237
+ data, {"max_size_mb", "recent_hours", "stale_threshold_minutes"}, "events"
238
+ )
236
239
  return EventsConfig(
237
240
  max_size_mb=_optional_int(
238
241
  data.get("max_size_mb"),
@@ -246,6 +249,12 @@ def _parse_events(value: object) -> EventsConfig:
246
249
  EventsConfig.recent_hours,
247
250
  min_value=0,
248
251
  ),
252
+ stale_threshold_minutes=_optional_int(
253
+ data.get("stale_threshold_minutes"),
254
+ "events.stale_threshold_minutes",
255
+ EventsConfig.stale_threshold_minutes,
256
+ min_value=1,
257
+ ),
249
258
  )
250
259
 
251
260
 
@@ -132,6 +132,12 @@ def _apply_env_overrides(data: dict, env: Mapping[str, str]) -> None:
132
132
  if parsed is not None:
133
133
  data["events"]["recent_hours"] = parsed
134
134
 
135
+ stale_threshold_override = env.get("CLAUDE_TEAM_STALE_THRESHOLD_MINUTES")
136
+ if stale_threshold_override:
137
+ parsed = _parse_int_override(stale_threshold_override)
138
+ if parsed is not None:
139
+ data["events"]["stale_threshold_minutes"] = parsed
140
+
135
141
 
136
142
  def _parse_int_override(raw_value: str) -> int | None:
137
143
  # Parse env overrides as integers; invalid values are ignored.
@@ -253,6 +259,7 @@ _FIELD_PARSERS: dict[str, Callable[[str, str], object]] = {
253
259
  ),
254
260
  "events.max_size_mb": _parse_int,
255
261
  "events.recent_hours": _parse_int,
262
+ "events.stale_threshold_minutes": _parse_int,
256
263
  "issue_tracker.override": lambda value, field: _parse_optional_literal(
257
264
  value,
258
265
  field,
@@ -5,12 +5,17 @@ Tracks all spawned Claude Code sessions, maintaining the mapping between
5
5
  our session IDs, terminal session handles, and Claude JSONL session IDs.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import uuid
9
11
  from dataclasses import dataclass, field
10
- from datetime import datetime
12
+ from datetime import datetime, timezone
11
13
  from enum import Enum
12
14
  from pathlib import Path
13
- from typing import Literal, Optional
15
+ from typing import TYPE_CHECKING, Literal, Optional, Union
16
+
17
+ if TYPE_CHECKING:
18
+ from claude_team.events import WorkerEvent
14
19
 
15
20
  from .session_state import (
16
21
  find_codex_session_by_internal_id,
@@ -22,6 +27,12 @@ from .terminal_backends import TerminalSession
22
27
  # Type alias for supported agent types
23
28
  AgentType = Literal["claude", "codex"]
24
29
 
30
+ # Type alias for event log states (from WorkerPoller snapshots)
31
+ EventState = Literal["idle", "active", "closed"]
32
+
33
+ # Type alias for session source provenance
34
+ SessionSource = Literal["registry", "event_log"]
35
+
25
36
 
26
37
  @dataclass(frozen=True)
27
38
  class TerminalId:
@@ -65,6 +76,128 @@ class SessionStatus(str, Enum):
65
76
  BUSY = "busy" # Claude is processing/responding
66
77
 
67
78
 
79
+ @dataclass(frozen=True)
80
+ class RecoveryReport:
81
+ """
82
+ Report from SessionRegistry.recover_from_events().
83
+
84
+ Provides counts of how sessions were handled during event log recovery.
85
+
86
+ Attributes:
87
+ added: Number of sessions added from event log
88
+ skipped: Number of sessions skipped (already in registry)
89
+ closed: Number of sessions marked as closed
90
+ timestamp: When recovery occurred
91
+ """
92
+
93
+ added: int
94
+ skipped: int
95
+ closed: int
96
+ timestamp: datetime
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class RecoveredSession:
101
+ """
102
+ Represents a session recovered from the event log.
103
+
104
+ This is a lightweight, read-only representation of a worker session
105
+ that was restored from persisted event snapshots. Unlike ManagedSession,
106
+ it has no terminal handle and cannot be controlled directly.
107
+
108
+ Used by SessionRegistry.recover_from_events() to populate the registry
109
+ with historical session data after MCP server restart.
110
+
111
+ Attributes:
112
+ session_id: Internal session ID (e.g., "a3f2b1c9")
113
+ name: Worker's friendly name (e.g., "Groucho")
114
+ project_path: Directory where the worker was running
115
+ terminal_id: Terminal identifier from snapshot (may be stale)
116
+ agent_type: "claude" or "codex"
117
+ status: Mapped SessionStatus (READY for idle, BUSY for active/closed)
118
+ last_activity: Last activity timestamp from snapshot
119
+ created_at: Session creation timestamp from snapshot
120
+ event_state: Raw state from event log ("idle", "active", or "closed")
121
+ recovered_at: Timestamp when this session was recovered
122
+ last_event_ts: Timestamp of the last event applied to this session
123
+ """
124
+
125
+ session_id: str
126
+ name: str
127
+ project_path: str
128
+ terminal_id: Optional[TerminalId]
129
+ agent_type: AgentType
130
+ status: SessionStatus
131
+ last_activity: datetime
132
+ created_at: datetime
133
+ event_state: EventState
134
+ recovered_at: datetime
135
+ last_event_ts: datetime
136
+
137
+ # Optional fields that may be present in snapshots
138
+ claude_session_id: Optional[str] = None
139
+ coordinator_annotation: Optional[str] = None
140
+ worktree_path: Optional[str] = None
141
+ main_repo_path: Optional[str] = None
142
+
143
+ @staticmethod
144
+ def map_event_state_to_status(event_state: EventState) -> SessionStatus:
145
+ """
146
+ Map event log state to SessionStatus.
147
+
148
+ Args:
149
+ event_state: State from event log ("idle", "active", "closed")
150
+
151
+ Returns:
152
+ Corresponding SessionStatus
153
+ """
154
+ if event_state == "idle":
155
+ return SessionStatus.READY
156
+ # Both "active" and "closed" map to BUSY
157
+ # (closed sessions were last known to be working)
158
+ return SessionStatus.BUSY
159
+
160
+ def to_dict(self) -> dict:
161
+ """
162
+ Convert to dictionary for MCP tool responses.
163
+
164
+ Matches ManagedSession.to_dict() output format but adds
165
+ source, event_state, recovered_at, and last_event_ts fields.
166
+ """
167
+ return {
168
+ # Core fields matching ManagedSession.to_dict()
169
+ "session_id": self.session_id,
170
+ "terminal_id": str(self.terminal_id) if self.terminal_id else None,
171
+ "name": self.name,
172
+ "project_path": self.project_path,
173
+ "claude_session_id": self.claude_session_id,
174
+ "status": self.status.value,
175
+ "created_at": self.created_at.isoformat(),
176
+ "last_activity": self.last_activity.isoformat(),
177
+ "coordinator_annotation": self.coordinator_annotation,
178
+ "worktree_path": self.worktree_path,
179
+ "main_repo_path": self.main_repo_path,
180
+ "agent_type": self.agent_type,
181
+ # Recovery-specific fields
182
+ "source": "event_log",
183
+ "event_state": self.event_state,
184
+ "recovered_at": self.recovered_at.isoformat(),
185
+ "last_event_ts": self.last_event_ts.isoformat(),
186
+ }
187
+
188
+ def is_idle(self) -> bool:
189
+ """
190
+ Check if this session is idle based on snapshot state.
191
+
192
+ Unlike ManagedSession.is_idle(), this does NOT access JSONL files.
193
+ It relies solely on the event_state from the snapshot.
194
+
195
+ Returns:
196
+ True if event_state is "idle", False otherwise
197
+ """
198
+ return self.event_state == "idle"
199
+
200
+
68
201
  @dataclass
69
202
  class ManagedSession:
70
203
  """
@@ -103,7 +236,12 @@ class ManagedSession:
103
236
  )
104
237
 
105
238
  def to_dict(self) -> dict:
106
- """Convert to dictionary for MCP tool responses."""
239
+ """
240
+ Convert to dictionary for MCP tool responses.
241
+
242
+ Includes source='registry' to indicate this is a live session
243
+ (as opposed to source='event_log' for recovered sessions).
244
+ """
107
245
  result = {
108
246
  "session_id": self.session_id,
109
247
  "terminal_id": str(self.terminal_id) if self.terminal_id else None,
@@ -117,6 +255,8 @@ class ManagedSession:
117
255
  "worktree_path": str(self.worktree_path) if self.worktree_path else None,
118
256
  "main_repo_path": str(self.main_repo_path) if self.main_repo_path else None,
119
257
  "agent_type": self.agent_type,
258
+ # Source field for distinguishing live vs recovered sessions
259
+ "source": "registry",
120
260
  }
121
261
  return result
122
262
 
@@ -273,11 +413,15 @@ class SessionRegistry:
273
413
 
274
414
  Maintains a collection of ManagedSession objects and provides
275
415
  methods for adding, retrieving, updating, and removing sessions.
416
+
417
+ Also tracks RecoveredSession objects from event log recovery, which
418
+ represent sessions discovered from persisted events after MCP restart.
276
419
  """
277
420
 
278
421
  def __init__(self):
279
422
  """Initialize an empty registry."""
280
423
  self._sessions: dict[str, ManagedSession] = {}
424
+ self._recovered_sessions: dict[str, RecoveredSession] = {}
281
425
 
282
426
  def _generate_id(self) -> str:
283
427
  """Generate a unique session ID as short UUID."""
@@ -372,26 +516,43 @@ class SessionRegistry:
372
516
  # 3. Try name (last resort)
373
517
  return self.get_by_name(identifier)
374
518
 
375
- def list_all(self) -> list[ManagedSession]:
519
+ def list_all(self) -> list[AnySession]:
376
520
  """
377
- Get all registered sessions.
521
+ Get all registered and recovered sessions.
522
+
523
+ Returns merged list of live ManagedSession objects and RecoveredSession
524
+ entries from event log recovery. Live sessions take precedence (recovered
525
+ sessions with matching IDs are excluded).
378
526
 
379
527
  Returns:
380
- List of all ManagedSession objects
528
+ List of all session objects (ManagedSession and RecoveredSession)
381
529
  """
382
- return list(self._sessions.values())
530
+ result: list[AnySession] = list(self._sessions.values())
531
+ # Add recovered sessions not shadowed by live sessions.
532
+ for session_id, recovered in self._recovered_sessions.items():
533
+ if session_id not in self._sessions:
534
+ result.append(recovered)
535
+ return result
383
536
 
384
- def list_by_status(self, status: SessionStatus) -> list[ManagedSession]:
537
+ def list_by_status(self, status: SessionStatus) -> list[AnySession]:
385
538
  """
386
539
  Get sessions filtered by status.
387
540
 
541
+ Includes both live ManagedSession and RecoveredSession entries that
542
+ match the specified status. Live sessions take precedence.
543
+
388
544
  Args:
389
545
  status: Status to filter by
390
546
 
391
547
  Returns:
392
- List of matching ManagedSession objects
548
+ List of matching session objects (ManagedSession and RecoveredSession)
393
549
  """
394
- return [s for s in self._sessions.values() if s.status == status]
550
+ result: list[AnySession] = [s for s in self._sessions.values() if s.status == status]
551
+ # Add recovered sessions not shadowed by live sessions.
552
+ for session_id, recovered in self._recovered_sessions.items():
553
+ if session_id not in self._sessions and recovered.status == status:
554
+ result.append(recovered)
555
+ return result
395
556
 
396
557
  def remove(self, session_id: str) -> Optional[ManagedSession]:
397
558
  """
@@ -428,6 +589,214 @@ class SessionRegistry:
428
589
  return True
429
590
  return False
430
591
 
592
+ def recover_from_events(
593
+ self,
594
+ snapshot: dict | None,
595
+ events: list[WorkerEvent],
596
+ ) -> RecoveryReport:
597
+ """
598
+ Recover session state from event log without overwriting live sessions.
599
+
600
+ Merges persisted event log state into the registry. Sessions already
601
+ in the registry are skipped to preserve live state. Sessions only
602
+ found in the event log are added as RecoveredSession entries.
603
+
604
+ Args:
605
+ snapshot: Output of get_latest_snapshot() (may be None)
606
+ events: Output of read_events_since(snapshot_ts) (may be empty)
607
+
608
+ Returns:
609
+ RecoveryReport with counts (added, skipped, closed)
610
+ """
611
+ now = datetime.now(timezone.utc)
612
+ added = 0
613
+ skipped = 0
614
+ closed = 0
615
+
616
+ # Build worker state from snapshot + events.
617
+ # worker_data[session_id] = dict with worker metadata
618
+ # worker_state[session_id] = "idle" | "active" | "closed"
619
+ # worker_last_event_ts[session_id] = datetime
620
+ worker_data: dict[str, dict] = {}
621
+ worker_state: dict[str, EventState] = {}
622
+ worker_last_event_ts: dict[str, datetime] = {}
623
+
624
+ # Process snapshot first to establish baseline state.
625
+ if snapshot is not None:
626
+ workers = snapshot.get("workers", [])
627
+ snapshot_ts_str = snapshot.get("ts")
628
+ snapshot_ts = self._parse_event_timestamp(snapshot_ts_str) if snapshot_ts_str else now
629
+ for worker in workers:
630
+ if not isinstance(worker, dict):
631
+ continue
632
+ session_id = self._extract_worker_id(worker)
633
+ if not session_id:
634
+ continue
635
+ worker_data[session_id] = worker
636
+ state = worker.get("state", "active")
637
+ if state in ("idle", "active", "closed"):
638
+ worker_state[session_id] = state
639
+ else:
640
+ worker_state[session_id] = "active"
641
+ worker_last_event_ts[session_id] = snapshot_ts
642
+
643
+ # Apply events in order to update state.
644
+ for event in events:
645
+ event_ts = self._parse_event_timestamp(event.ts)
646
+ if event.type == "snapshot":
647
+ # Process embedded workers in snapshot events.
648
+ workers = event.data.get("workers", [])
649
+ for worker in workers:
650
+ if not isinstance(worker, dict):
651
+ continue
652
+ session_id = self._extract_worker_id(worker)
653
+ if not session_id:
654
+ continue
655
+ worker_data[session_id] = worker
656
+ state = worker.get("state", "active")
657
+ if state in ("idle", "active", "closed"):
658
+ worker_state[session_id] = state
659
+ else:
660
+ worker_state[session_id] = "active"
661
+ worker_last_event_ts[session_id] = event_ts
662
+ elif event.worker_id:
663
+ # Handle individual worker events.
664
+ session_id = event.worker_id
665
+ worker_last_event_ts[session_id] = event_ts
666
+ # Update state based on event type.
667
+ if event.type == "worker_started":
668
+ worker_state[session_id] = "active"
669
+ # Merge event data into worker_data if not already present.
670
+ if session_id not in worker_data:
671
+ worker_data[session_id] = event.data or {}
672
+ else:
673
+ worker_data[session_id] = {**worker_data[session_id], **(event.data or {})}
674
+ elif event.type == "worker_idle":
675
+ worker_state[session_id] = "idle"
676
+ elif event.type == "worker_active":
677
+ worker_state[session_id] = "active"
678
+ elif event.type == "worker_closed":
679
+ worker_state[session_id] = "closed"
680
+
681
+ # Create RecoveredSession entries for workers not in the live registry.
682
+ for session_id, data in worker_data.items():
683
+ # Skip if already in live registry.
684
+ if session_id in self._sessions:
685
+ skipped += 1
686
+ continue
687
+
688
+ # Skip if already recovered (idempotent).
689
+ if session_id in self._recovered_sessions:
690
+ skipped += 1
691
+ continue
692
+
693
+ # Build RecoveredSession from data.
694
+ state = worker_state.get(session_id, "active")
695
+ last_event_ts = worker_last_event_ts.get(session_id, now)
696
+
697
+ # Track closed sessions.
698
+ if state == "closed":
699
+ closed += 1
700
+
701
+ recovered = self._build_recovered_session(
702
+ session_id=session_id,
703
+ data=data,
704
+ event_state=state,
705
+ recovered_at=now,
706
+ last_event_ts=last_event_ts,
707
+ )
708
+ self._recovered_sessions[session_id] = recovered
709
+ added += 1
710
+
711
+ return RecoveryReport(
712
+ added=added,
713
+ skipped=skipped,
714
+ closed=closed,
715
+ timestamp=now,
716
+ )
717
+
718
+ def _parse_event_timestamp(self, ts_str: str | None) -> datetime:
719
+ """Parse ISO timestamp from event log, returning UTC datetime."""
720
+ if not ts_str:
721
+ return datetime.now(timezone.utc)
722
+ # Normalize Zulu timestamps.
723
+ if ts_str.endswith("Z"):
724
+ ts_str = ts_str[:-1] + "+00:00"
725
+ try:
726
+ parsed = datetime.fromisoformat(ts_str)
727
+ if parsed.tzinfo is None:
728
+ return parsed.replace(tzinfo=timezone.utc)
729
+ return parsed.astimezone(timezone.utc)
730
+ except ValueError:
731
+ return datetime.now(timezone.utc)
732
+
733
+ def _extract_worker_id(self, worker: dict) -> str | None:
734
+ """Extract session ID from worker snapshot data."""
735
+ for key in ("session_id", "worker_id", "id"):
736
+ value = worker.get(key)
737
+ if value:
738
+ return str(value)
739
+ return None
740
+
741
+ def _build_recovered_session(
742
+ self,
743
+ session_id: str,
744
+ data: dict,
745
+ event_state: EventState,
746
+ recovered_at: datetime,
747
+ last_event_ts: datetime,
748
+ ) -> RecoveredSession:
749
+ """
750
+ Build a RecoveredSession from event log data.
751
+
752
+ Args:
753
+ session_id: The worker session ID
754
+ data: Worker data from snapshot or events
755
+ event_state: Current state from events ("idle", "active", "closed")
756
+ recovered_at: When recovery is occurring
757
+ last_event_ts: Timestamp of last event for this worker
758
+
759
+ Returns:
760
+ RecoveredSession instance
761
+ """
762
+ # Extract fields from data with sensible defaults.
763
+ name = data.get("name") or session_id
764
+ project_path = data.get("project_path") or data.get("project") or ""
765
+ agent_type = data.get("agent_type", "claude")
766
+ if agent_type not in ("claude", "codex"):
767
+ agent_type = "claude"
768
+
769
+ # Parse terminal_id if present.
770
+ terminal_id = None
771
+ terminal_id_str = data.get("terminal_id")
772
+ if terminal_id_str:
773
+ terminal_id = TerminalId.from_string(str(terminal_id_str))
774
+
775
+ # Parse timestamps with fallbacks.
776
+ created_at = self._parse_event_timestamp(data.get("created_at"))
777
+ last_activity = self._parse_event_timestamp(data.get("last_activity")) or last_event_ts
778
+
779
+ # Map event state to SessionStatus.
780
+ status = RecoveredSession.map_event_state_to_status(event_state)
781
+
782
+ return RecoveredSession(
783
+ session_id=session_id,
784
+ name=name,
785
+ project_path=project_path,
786
+ terminal_id=terminal_id,
787
+ agent_type=agent_type,
788
+ status=status,
789
+ last_activity=last_activity,
790
+ created_at=created_at,
791
+ event_state=event_state,
792
+ recovered_at=recovered_at,
793
+ last_event_ts=last_event_ts,
794
+ claude_session_id=data.get("claude_session_id"),
795
+ coordinator_annotation=data.get("coordinator_annotation"),
796
+ worktree_path=data.get("worktree_path"),
797
+ main_repo_path=data.get("main_repo_path"),
798
+ )
799
+
431
800
  def count(self) -> int:
432
801
  """Return the number of registered sessions."""
433
802
  return len(self._sessions)
@@ -441,3 +810,8 @@ class SessionRegistry:
441
810
 
442
811
  def __contains__(self, session_id: str) -> bool:
443
812
  return session_id in self._sessions
813
+
814
+
815
+ # Union type for session types returned by registry
816
+ # Defined at module level after both classes for type checking
817
+ AnySession = Union[ManagedSession, RecoveredSession]
claude_team_mcp/server.py CHANGED
@@ -15,9 +15,10 @@ from dataclasses import dataclass
15
15
  from mcp.server.fastmcp import Context, FastMCP
16
16
  from mcp.server.session import ServerSession
17
17
 
18
+ from claude_team.events import get_latest_snapshot, read_events_since
18
19
  from claude_team.poller import WorkerPoller
19
20
 
20
- from .registry import SessionRegistry
21
+ from .registry import RecoveryReport, SessionRegistry
21
22
  from .terminal_backends import ItermBackend, TerminalBackend, TmuxBackend, select_backend_id
22
23
  from .tools import register_all_tools
23
24
  from .utils import error_response, HINTS
@@ -43,6 +44,7 @@ logger.info("=== Claude Team MCP Server Starting ===")
43
44
 
44
45
  _global_registry: SessionRegistry | None = None
45
46
  _global_poller: WorkerPoller | None = None
47
+ _recovery_attempted: bool = False
46
48
 
47
49
 
48
50
  def get_global_registry() -> SessionRegistry:
@@ -63,6 +65,62 @@ def get_global_poller(registry: SessionRegistry) -> WorkerPoller:
63
65
  return _global_poller
64
66
 
65
67
 
68
+ def recover_registry(registry: SessionRegistry) -> RecoveryReport | None:
69
+ """
70
+ Attempt to recover session state from the event log.
71
+
72
+ Reads the latest snapshot and subsequent events from ~/.claude-team/events.jsonl,
73
+ then feeds them into registry.recover_from_events() to seed the registry with
74
+ historical session data.
75
+
76
+ Args:
77
+ registry: The SessionRegistry to populate
78
+
79
+ Returns:
80
+ RecoveryReport if recovery was performed, None if no events available
81
+ """
82
+ global _recovery_attempted
83
+ _recovery_attempted = True
84
+
85
+ # Get the latest snapshot from the event log.
86
+ snapshot = get_latest_snapshot()
87
+
88
+ # Parse snapshot timestamp to filter subsequent events.
89
+ # The snapshot dict should contain a 'ts' field with the timestamp.
90
+ since = None
91
+ if snapshot is not None:
92
+ ts_str = snapshot.get("ts")
93
+ if ts_str:
94
+ from datetime import datetime, timezone
95
+
96
+ # Normalize Zulu timestamps.
97
+ if ts_str.endswith("Z"):
98
+ ts_str = ts_str[:-1] + "+00:00"
99
+ try:
100
+ since = datetime.fromisoformat(ts_str)
101
+ if since.tzinfo is None:
102
+ since = since.replace(tzinfo=timezone.utc)
103
+ except ValueError:
104
+ since = None
105
+
106
+ # Read events since the snapshot (or all events if no snapshot).
107
+ events = read_events_since(since=since, limit=10000)
108
+
109
+ # If no snapshot and no events, nothing to recover.
110
+ if snapshot is None and not events:
111
+ logger.info("No event log data available for recovery")
112
+ return None
113
+
114
+ # Perform recovery.
115
+ report = registry.recover_from_events(snapshot, events)
116
+ return report
117
+
118
+
119
+ def is_recovery_attempted() -> bool:
120
+ """Check whether recovery has been attempted this session."""
121
+ return _recovery_attempted
122
+
123
+
66
124
  # =============================================================================
67
125
  # Application Context
68
126
  # =============================================================================
@@ -216,6 +274,22 @@ async def app_lifespan(
216
274
  terminal_backend=backend,
217
275
  registry=get_global_registry(),
218
276
  )
277
+
278
+ # Attempt eager recovery from event log to seed the registry with historical
279
+ # session data. This ensures list_workers returns useful data after restart.
280
+ if not is_recovery_attempted():
281
+ logger.info("Attempting eager recovery from event log...")
282
+ report = recover_registry(ctx.registry)
283
+ if report is not None:
284
+ logger.info(
285
+ "Event log recovery complete: added=%d, skipped=%d, closed=%d",
286
+ report.added,
287
+ report.skipped,
288
+ report.closed,
289
+ )
290
+ else:
291
+ logger.info("No event log data available for recovery")
292
+
219
293
  poller: WorkerPoller | None = None
220
294
  if enable_poller:
221
295
  poller = get_global_poller(ctx.registry)
@@ -7,7 +7,6 @@ Provides a TerminalBackend implementation backed by tmux CLI commands.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
- import hashlib
11
10
  import re
12
11
  import subprocess
13
12
  import uuid
@@ -46,7 +45,6 @@ ISSUE_ID_PATTERN = re.compile(r"\b[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]*\d[A-Za-z0-9]
46
45
  SHELL_READY_MARKER = "CLAUDE_TEAM_READY_7f3a9c"
47
46
  CODEX_PRE_ENTER_DELAY = 0.5
48
47
  TMUX_SESSION_PREFIX = "claude-team"
49
- TMUX_SESSION_HASH_LEN = 8
50
48
  TMUX_SESSION_SLUG_MAX = 32
51
49
  TMUX_SESSION_FALLBACK = "project"
52
50
  TMUX_SESSION_PREFIXED = f"{TMUX_SESSION_PREFIX}-"
@@ -92,15 +90,15 @@ def project_name_from_path(project_path: str | None) -> str | None:
92
90
 
93
91
 
94
92
  def tmux_session_name_for_project(project_path: str | None) -> str:
95
- """Return the per-project tmux session name for a given project path."""
93
+ """Return the per-project tmux session name for a given project path.
94
+
95
+ Worktree paths produce the same session name as their main repository
96
+ since project_name_from_path extracts the project name from the path.
97
+ Session names follow the format: claude-team-{project-slug}
98
+ """
96
99
  project_name = project_name_from_path(project_path) or TMUX_SESSION_FALLBACK
97
100
  slug = _tmux_safe_slug(project_name)
98
- if project_path:
99
- digest_source = project_path
100
- else:
101
- digest_source = uuid.uuid4().hex
102
- digest = hashlib.sha1(digest_source.encode("utf-8")).hexdigest()[:TMUX_SESSION_HASH_LEN]
103
- return f"{TMUX_SESSION_PREFIXED}{slug}-{digest}"
101
+ return f"{TMUX_SESSION_PREFIXED}{slug}"
104
102
 
105
103
 
106
104
  # Determine whether a tmux session is managed by claude-team.
@@ -20,6 +20,7 @@ from . import poll_worker_changes
20
20
  from . import read_worker_logs
21
21
  from . import spawn_workers
22
22
  from . import wait_idle_workers
23
+ from . import worker_events
23
24
 
24
25
 
25
26
  def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
@@ -42,6 +43,7 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
42
43
  poll_worker_changes.register_tools(mcp)
43
44
  read_worker_logs.register_tools(mcp)
44
45
  wait_idle_workers.register_tools(mcp)
46
+ worker_events.register_tools(mcp)
45
47
 
46
48
  # Tools that need ensure_connection for terminal backend operations
47
49
  adopt_worker.register_tools(mcp, ensure_connection)