claude-team-mcp 0.8.2__py3-none-any.whl → 0.9.1__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 +12 -3
- claude_team_mcp/config_cli.py +7 -0
- claude_team_mcp/registry.py +384 -10
- claude_team_mcp/server.py +75 -1
- claude_team_mcp/tools/__init__.py +2 -0
- claude_team_mcp/tools/adopt_worker.py +4 -1
- claude_team_mcp/tools/close_workers.py +4 -1
- claude_team_mcp/tools/discover_workers.py +4 -1
- claude_team_mcp/tools/list_workers.py +34 -6
- claude_team_mcp/tools/list_worktrees.py +4 -1
- claude_team_mcp/tools/message_workers.py +6 -2
- claude_team_mcp/tools/poll_worker_changes.py +12 -2
- claude_team_mcp/tools/read_worker_logs.py +6 -2
- claude_team_mcp/tools/wait_idle_workers.py +8 -3
- claude_team_mcp/tools/worker_events.py +279 -0
- claude_team_mcp-0.9.1.dist-info/METADATA +565 -0
- {claude_team_mcp-0.8.2.dist-info → claude_team_mcp-0.9.1.dist-info}/RECORD +19 -18
- claude_team_mcp-0.8.2.dist-info/METADATA +0 -427
- {claude_team_mcp-0.8.2.dist-info → claude_team_mcp-0.9.1.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.8.2.dist-info → claude_team_mcp-0.9.1.dist-info}/entry_points.txt +0 -0
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(
|
|
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
|
|
claude_team_mcp/config_cli.py
CHANGED
|
@@ -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,
|
claude_team_mcp/registry.py
CHANGED
|
@@ -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
|
-
"""
|
|
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[
|
|
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
|
|
528
|
+
List of all session objects (ManagedSession and RecoveredSession)
|
|
381
529
|
"""
|
|
382
|
-
|
|
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[
|
|
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
|
|
548
|
+
List of matching session objects (ManagedSession and RecoveredSession)
|
|
393
549
|
"""
|
|
394
|
-
|
|
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)
|
|
@@ -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)
|
|
@@ -35,7 +35,7 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
35
35
|
iterm_session_id: str | None = None,
|
|
36
36
|
tmux_pane_id: str | None = None,
|
|
37
37
|
session_name: str | None = None,
|
|
38
|
-
max_age: int = 3600,
|
|
38
|
+
max_age: int | None = 3600,
|
|
39
39
|
) -> dict:
|
|
40
40
|
"""
|
|
41
41
|
Adopt an existing terminal Claude Code or Codex session into the MCP registry.
|
|
@@ -53,6 +53,9 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
53
53
|
Returns:
|
|
54
54
|
Dict with adopted worker info, or error if session not found
|
|
55
55
|
"""
|
|
56
|
+
# Handle None values from MCP clients that send explicit null for omitted params
|
|
57
|
+
max_age = max_age if max_age is not None else 3600
|
|
58
|
+
|
|
56
59
|
app_ctx = ctx.request_context.lifespan_context
|
|
57
60
|
registry = app_ctx.registry
|
|
58
61
|
|
|
@@ -133,7 +133,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
133
133
|
async def close_workers(
|
|
134
134
|
ctx: Context[ServerSession, "AppContext"],
|
|
135
135
|
session_ids: list[str],
|
|
136
|
-
force: bool = False,
|
|
136
|
+
force: bool | None = False,
|
|
137
137
|
) -> dict:
|
|
138
138
|
"""
|
|
139
139
|
Close one or more managed Claude Code sessions.
|
|
@@ -163,6 +163,9 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
163
163
|
- success_count: Number of sessions closed successfully
|
|
164
164
|
- failure_count: Number of sessions that failed to close
|
|
165
165
|
"""
|
|
166
|
+
# Handle None values from MCP clients that send explicit null for omitted params
|
|
167
|
+
force = force if force is not None else False
|
|
168
|
+
|
|
166
169
|
app_ctx = ctx.request_context.lifespan_context
|
|
167
170
|
registry = app_ctx.registry
|
|
168
171
|
backend = app_ctx.terminal_backend
|