claude-team-mcp 0.1.0__py3-none-any.whl → 0.2.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/colors.py +4 -4
- claude_team_mcp/formatting.py +46 -49
- claude_team_mcp/idle_detection.py +208 -0
- claude_team_mcp/iterm_utils.py +204 -306
- claude_team_mcp/names.py +424 -0
- claude_team_mcp/profile.py +44 -505
- claude_team_mcp/registry.py +140 -113
- claude_team_mcp/server.py +42 -2107
- claude_team_mcp/session_state.py +384 -123
- claude_team_mcp/tools/__init__.py +52 -0
- claude_team_mcp/tools/adopt_worker.py +122 -0
- claude_team_mcp/tools/annotate_worker.py +57 -0
- claude_team_mcp/tools/bd_help.py +42 -0
- claude_team_mcp/tools/check_idle_workers.py +98 -0
- claude_team_mcp/tools/close_workers.py +195 -0
- claude_team_mcp/tools/discover_workers.py +129 -0
- claude_team_mcp/tools/examine_worker.py +56 -0
- claude_team_mcp/tools/list_workers.py +76 -0
- claude_team_mcp/tools/list_worktrees.py +106 -0
- claude_team_mcp/tools/message_workers.py +217 -0
- claude_team_mcp/tools/read_worker_logs.py +158 -0
- claude_team_mcp/tools/spawn_workers.py +601 -0
- claude_team_mcp/tools/wait_idle_workers.py +148 -0
- claude_team_mcp/utils/__init__.py +17 -0
- claude_team_mcp/utils/constants.py +81 -0
- claude_team_mcp/utils/errors.py +87 -0
- claude_team_mcp/utils/worktree_detection.py +79 -0
- claude_team_mcp/worker_prompt.py +164 -0
- claude_team_mcp/worktree.py +532 -0
- claude_team_mcp-0.2.0.dist-info/METADATA +377 -0
- claude_team_mcp-0.2.0.dist-info/RECORD +36 -0
- claude_team_mcp/task_completion.py +0 -681
- claude_team_mcp-0.1.0.dist-info/METADATA +0 -235
- claude_team_mcp-0.1.0.dist-info/RECORD +0 -15
- {claude_team_mcp-0.1.0.dist-info → claude_team_mcp-0.2.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.1.0.dist-info → claude_team_mcp-0.2.0.dist-info}/entry_points.txt +0 -0
claude_team_mcp/colors.py
CHANGED
|
@@ -9,7 +9,7 @@ import colorsys
|
|
|
9
9
|
from typing import TYPE_CHECKING
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
-
import
|
|
12
|
+
from iterm2.color import Color as ItermColor
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# Golden ratio conjugate (φ - 1), used for even hue distribution
|
|
@@ -24,7 +24,7 @@ def generate_tab_color(
|
|
|
24
24
|
index: int,
|
|
25
25
|
saturation: float = DEFAULT_SATURATION,
|
|
26
26
|
lightness: float = DEFAULT_LIGHTNESS,
|
|
27
|
-
) -> "
|
|
27
|
+
) -> "ItermColor":
|
|
28
28
|
"""
|
|
29
29
|
Generate a distinct tab color for a given index.
|
|
30
30
|
|
|
@@ -51,7 +51,7 @@ def generate_tab_color(
|
|
|
51
51
|
# Colors remain visually distinct even for many sessions
|
|
52
52
|
color10 = generate_tab_color(10)
|
|
53
53
|
"""
|
|
54
|
-
import
|
|
54
|
+
from iterm2.color import Color
|
|
55
55
|
|
|
56
56
|
# Calculate hue using golden ratio distribution
|
|
57
57
|
# Multiply index by golden ratio conjugate and take fractional part
|
|
@@ -62,7 +62,7 @@ def generate_tab_color(
|
|
|
62
62
|
r, g, b = colorsys.hls_to_rgb(hue, lightness, saturation)
|
|
63
63
|
|
|
64
64
|
# iterm2.Color expects integer RGB values 0-255
|
|
65
|
-
return
|
|
65
|
+
return Color(
|
|
66
66
|
r=int(r * 255),
|
|
67
67
|
g=int(g * 255),
|
|
68
68
|
b=int(b * 255),
|
claude_team_mcp/formatting.py
CHANGED
|
@@ -11,18 +11,18 @@ from typing import Optional
|
|
|
11
11
|
def format_session_title(
|
|
12
12
|
session_name: str,
|
|
13
13
|
issue_id: Optional[str] = None,
|
|
14
|
-
|
|
14
|
+
annotation: Optional[str] = None,
|
|
15
15
|
) -> str:
|
|
16
16
|
"""
|
|
17
17
|
Format a session title for iTerm2 tab display.
|
|
18
18
|
|
|
19
19
|
Creates a formatted title string combining session name, optional issue ID,
|
|
20
|
-
and optional
|
|
20
|
+
and optional annotation.
|
|
21
21
|
|
|
22
22
|
Args:
|
|
23
23
|
session_name: Session identifier (e.g., "worker-1")
|
|
24
24
|
issue_id: Optional issue/ticket ID (e.g., "cic-3dj")
|
|
25
|
-
|
|
25
|
+
annotation: Optional task annotation (e.g., "profile module")
|
|
26
26
|
|
|
27
27
|
Returns:
|
|
28
28
|
Formatted title string.
|
|
@@ -31,7 +31,7 @@ def format_session_title(
|
|
|
31
31
|
>>> format_session_title("worker-1", "cic-3dj", "profile module")
|
|
32
32
|
'[worker-1] cic-3dj: profile module'
|
|
33
33
|
|
|
34
|
-
>>> format_session_title("worker-2",
|
|
34
|
+
>>> format_session_title("worker-2", annotation="refactor auth")
|
|
35
35
|
'[worker-2] refactor auth'
|
|
36
36
|
|
|
37
37
|
>>> format_session_title("worker-3")
|
|
@@ -40,69 +40,66 @@ def format_session_title(
|
|
|
40
40
|
# Build the title in parts
|
|
41
41
|
title_parts = [f"[{session_name}]"]
|
|
42
42
|
|
|
43
|
-
if issue_id and
|
|
44
|
-
# Both issue ID and
|
|
45
|
-
title_parts.append(f"{issue_id}: {
|
|
43
|
+
if issue_id and annotation:
|
|
44
|
+
# Both issue ID and annotation: "issue_id: annotation"
|
|
45
|
+
title_parts.append(f"{issue_id}: {annotation}")
|
|
46
46
|
elif issue_id:
|
|
47
47
|
# Only issue ID
|
|
48
48
|
title_parts.append(issue_id)
|
|
49
|
-
elif
|
|
50
|
-
# Only
|
|
51
|
-
title_parts.append(
|
|
49
|
+
elif annotation:
|
|
50
|
+
# Only annotation
|
|
51
|
+
title_parts.append(annotation)
|
|
52
52
|
|
|
53
53
|
return " ".join(title_parts)
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def format_badge_text(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
name: str,
|
|
58
|
+
bead: Optional[str] = None,
|
|
59
|
+
annotation: Optional[str] = None,
|
|
60
|
+
max_annotation_length: int = 30,
|
|
60
61
|
) -> str:
|
|
61
62
|
"""
|
|
62
|
-
Format
|
|
63
|
+
Format badge text with bead/name on first line, annotation on second.
|
|
63
64
|
|
|
64
|
-
Creates a
|
|
65
|
-
|
|
65
|
+
Creates a multi-line string suitable for iTerm2 badge display:
|
|
66
|
+
- Line 1: bead ID if provided, otherwise worker name
|
|
67
|
+
- Line 2: annotation (if provided), truncated if too long
|
|
66
68
|
|
|
67
69
|
Args:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
name: Worker name (used if bead not provided)
|
|
71
|
+
bead: Optional bead/issue ID (e.g., "cic-3dj")
|
|
72
|
+
annotation: Optional task annotation
|
|
73
|
+
max_annotation_length: Maximum length for annotation line (default 30)
|
|
71
74
|
|
|
72
75
|
Returns:
|
|
73
|
-
|
|
76
|
+
Badge text, potentially multi-line.
|
|
74
77
|
|
|
75
78
|
Examples:
|
|
76
|
-
>>> format_badge_text("cic-3dj", "profile module"
|
|
77
|
-
'cic-3dj
|
|
79
|
+
>>> format_badge_text("Groucho", "cic-3dj", "profile module")
|
|
80
|
+
'cic-3dj\\nprofile module'
|
|
78
81
|
|
|
79
|
-
>>> format_badge_text("
|
|
80
|
-
'
|
|
82
|
+
>>> format_badge_text("Groucho", annotation="quick task")
|
|
83
|
+
'Groucho\\nquick task'
|
|
81
84
|
|
|
82
|
-
>>> format_badge_text(
|
|
83
|
-
'
|
|
85
|
+
>>> format_badge_text("Groucho", "cic-3dj")
|
|
86
|
+
'cic-3dj'
|
|
84
87
|
|
|
85
|
-
>>> format_badge_text()
|
|
86
|
-
''
|
|
87
|
-
"""
|
|
88
|
-
if max_length < 4:
|
|
89
|
-
# Too short to meaningfully truncate
|
|
90
|
-
max_length = 4
|
|
88
|
+
>>> format_badge_text("Groucho")
|
|
89
|
+
'Groucho'
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
truncated = full_text[: max_length - 3].rstrip()
|
|
108
|
-
return f"{truncated}..."
|
|
91
|
+
>>> format_badge_text("Groucho", annotation="a very long annotation here", max_annotation_length=20)
|
|
92
|
+
'Groucho\\na very long annot...'
|
|
93
|
+
"""
|
|
94
|
+
# First line: bead if provided, otherwise name
|
|
95
|
+
first_line = bead if bead else name
|
|
96
|
+
|
|
97
|
+
# Second line: annotation if provided, with truncation
|
|
98
|
+
if annotation:
|
|
99
|
+
if len(annotation) > max_annotation_length:
|
|
100
|
+
# Reserve 3 chars for ellipsis
|
|
101
|
+
truncated = annotation[: max_annotation_length - 3].rstrip()
|
|
102
|
+
annotation = f"{truncated}..."
|
|
103
|
+
return f"{first_line}\n{annotation}"
|
|
104
|
+
|
|
105
|
+
return first_line
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Idle Detection for Claude Team Workers
|
|
3
|
+
|
|
4
|
+
Detects when workers are idle (finished responding) using Stop hook signals.
|
|
5
|
+
Workers are spawned with a Stop hook that fires when Claude finishes responding.
|
|
6
|
+
The hook embeds a session ID marker in the JSONL - if it fired with no subsequent
|
|
7
|
+
messages, the worker is idle.
|
|
8
|
+
|
|
9
|
+
Binary state model:
|
|
10
|
+
- Idle: Stop hook fired, no messages after it
|
|
11
|
+
- Working: Either no stop hook yet, or messages exist after the last one
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .session_state import is_session_stopped
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("claude-team-mcp")
|
|
23
|
+
|
|
24
|
+
# Default timeout for waiting operations (10 minutes)
|
|
25
|
+
DEFAULT_TIMEOUT = 600.0
|
|
26
|
+
DEFAULT_POLL_INTERVAL = 2.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def is_idle(jsonl_path: Path, session_id: str) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
Check if a session is idle (finished responding).
|
|
32
|
+
|
|
33
|
+
A session is idle if its Stop hook has fired and no messages
|
|
34
|
+
have been sent after it.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
jsonl_path: Path to the session JSONL file
|
|
38
|
+
session_id: The session ID (matches marker in Stop hook)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if idle, False if working or file not found
|
|
42
|
+
"""
|
|
43
|
+
if not jsonl_path.exists():
|
|
44
|
+
return False
|
|
45
|
+
return is_session_stopped(jsonl_path, session_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def wait_for_idle(
|
|
49
|
+
jsonl_path: Path,
|
|
50
|
+
session_id: str,
|
|
51
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
52
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Wait for a session to become idle.
|
|
56
|
+
|
|
57
|
+
Polls until the Stop hook fires or timeout is reached.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
jsonl_path: Path to session JSONL file
|
|
61
|
+
session_id: The session ID to check
|
|
62
|
+
timeout: Maximum seconds to wait (default 600s / 10 min)
|
|
63
|
+
poll_interval: Seconds between checks
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Dict with {idle: bool, session_id: str, waited_seconds: float, timed_out: bool}
|
|
67
|
+
"""
|
|
68
|
+
start = time.time()
|
|
69
|
+
|
|
70
|
+
while time.time() - start < timeout:
|
|
71
|
+
if is_idle(jsonl_path, session_id):
|
|
72
|
+
return {
|
|
73
|
+
"idle": True,
|
|
74
|
+
"session_id": session_id,
|
|
75
|
+
"waited_seconds": time.time() - start,
|
|
76
|
+
"timed_out": False,
|
|
77
|
+
}
|
|
78
|
+
await asyncio.sleep(poll_interval)
|
|
79
|
+
|
|
80
|
+
# Timeout
|
|
81
|
+
return {
|
|
82
|
+
"idle": False,
|
|
83
|
+
"session_id": session_id,
|
|
84
|
+
"waited_seconds": timeout,
|
|
85
|
+
"timed_out": True,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class SessionInfo:
|
|
91
|
+
"""Info needed to check a session's idle state."""
|
|
92
|
+
|
|
93
|
+
jsonl_path: Path
|
|
94
|
+
session_id: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def wait_for_any_idle(
|
|
98
|
+
sessions: list[SessionInfo],
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
100
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
101
|
+
) -> dict:
|
|
102
|
+
"""
|
|
103
|
+
Wait for ANY session to become idle.
|
|
104
|
+
|
|
105
|
+
Returns as soon as the first session becomes idle.
|
|
106
|
+
Useful for pipeline patterns where you want to process results
|
|
107
|
+
as they become available.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
sessions: List of SessionInfo to monitor
|
|
111
|
+
timeout: Maximum seconds to wait
|
|
112
|
+
poll_interval: Seconds between checks
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with {
|
|
116
|
+
idle_session_id: str | None, # First session to become idle
|
|
117
|
+
idle: bool, # True if any session became idle
|
|
118
|
+
waited_seconds: float,
|
|
119
|
+
timed_out: bool,
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
start = time.time()
|
|
123
|
+
|
|
124
|
+
while time.time() - start < timeout:
|
|
125
|
+
for session in sessions:
|
|
126
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
127
|
+
return {
|
|
128
|
+
"idle_session_id": session.session_id,
|
|
129
|
+
"idle": True,
|
|
130
|
+
"waited_seconds": time.time() - start,
|
|
131
|
+
"timed_out": False,
|
|
132
|
+
}
|
|
133
|
+
await asyncio.sleep(poll_interval)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"idle_session_id": None,
|
|
137
|
+
"idle": False,
|
|
138
|
+
"waited_seconds": timeout,
|
|
139
|
+
"timed_out": True,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def wait_for_all_idle(
|
|
144
|
+
sessions: list[SessionInfo],
|
|
145
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
146
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL,
|
|
147
|
+
) -> dict:
|
|
148
|
+
"""
|
|
149
|
+
Wait for ALL sessions to become idle.
|
|
150
|
+
|
|
151
|
+
Returns when every session has become idle, or on timeout.
|
|
152
|
+
Useful for fan-out/fan-in patterns where you need all results
|
|
153
|
+
before proceeding.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
sessions: List of SessionInfo to monitor
|
|
157
|
+
timeout: Maximum seconds to wait
|
|
158
|
+
poll_interval: Seconds between checks
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dict with {
|
|
162
|
+
idle_session_ids: list[str], # Sessions that are idle
|
|
163
|
+
all_idle: bool, # True if all sessions became idle
|
|
164
|
+
waiting_on: list[str], # Sessions still working (if timed out)
|
|
165
|
+
waited_seconds: float,
|
|
166
|
+
timed_out: bool,
|
|
167
|
+
}
|
|
168
|
+
"""
|
|
169
|
+
start = time.time()
|
|
170
|
+
|
|
171
|
+
while time.time() - start < timeout:
|
|
172
|
+
idle_sessions = []
|
|
173
|
+
working_sessions = []
|
|
174
|
+
|
|
175
|
+
for session in sessions:
|
|
176
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
177
|
+
idle_sessions.append(session.session_id)
|
|
178
|
+
else:
|
|
179
|
+
working_sessions.append(session.session_id)
|
|
180
|
+
|
|
181
|
+
if not working_sessions:
|
|
182
|
+
# All idle!
|
|
183
|
+
return {
|
|
184
|
+
"idle_session_ids": idle_sessions,
|
|
185
|
+
"all_idle": True,
|
|
186
|
+
"waiting_on": [],
|
|
187
|
+
"waited_seconds": time.time() - start,
|
|
188
|
+
"timed_out": False,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await asyncio.sleep(poll_interval)
|
|
192
|
+
|
|
193
|
+
# Timeout - return final state
|
|
194
|
+
idle_sessions = []
|
|
195
|
+
working_sessions = []
|
|
196
|
+
for session in sessions:
|
|
197
|
+
if is_idle(session.jsonl_path, session.session_id):
|
|
198
|
+
idle_sessions.append(session.session_id)
|
|
199
|
+
else:
|
|
200
|
+
working_sessions.append(session.session_id)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"idle_session_ids": idle_sessions,
|
|
204
|
+
"all_idle": False,
|
|
205
|
+
"waiting_on": working_sessions,
|
|
206
|
+
"waited_seconds": timeout,
|
|
207
|
+
"timed_out": True,
|
|
208
|
+
}
|