claude-team-mcp 0.6.1__py3-none-any.whl → 0.7.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/__init__.py +11 -0
- claude_team/events.py +477 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +95 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +31 -0
- claude_team_mcp/terminal_backends/base.py +106 -0
- claude_team_mcp/terminal_backends/iterm.py +251 -0
- claude_team_mcp/terminal_backends/tmux.py +646 -0
- claude_team_mcp/tools/__init__.py +4 -2
- claude_team_mcp/tools/adopt_worker.py +89 -32
- claude_team_mcp/tools/close_workers.py +39 -10
- claude_team_mcp/tools/discover_workers.py +176 -32
- claude_team_mcp/tools/list_workers.py +29 -0
- claude_team_mcp/tools/message_workers.py +35 -5
- claude_team_mcp/tools/poll_worker_changes.py +227 -0
- claude_team_mcp/tools/spawn_workers.py +221 -142
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +59 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.7.0.dist-info/RECORD +52 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
|
@@ -20,13 +20,42 @@ from ..idle_detection import (
|
|
|
20
20
|
SessionInfo,
|
|
21
21
|
)
|
|
22
22
|
from ..issue_tracker import detect_issue_tracker
|
|
23
|
-
from ..iterm_utils import
|
|
23
|
+
from ..iterm_utils import CODEX_PRE_ENTER_DELAY
|
|
24
24
|
from ..registry import SessionStatus
|
|
25
|
+
from ..terminal_backends import ItermBackend
|
|
25
26
|
from ..utils import build_worker_message_hint, error_response, HINTS
|
|
26
27
|
|
|
27
28
|
logger = logging.getLogger("claude-team-mcp")
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def _compute_prompt_delay(text: str, agent_type: str) -> float:
|
|
32
|
+
"""Compute a safe delay before sending Enter for a prompt."""
|
|
33
|
+
line_count = text.count("\n")
|
|
34
|
+
char_count = len(text)
|
|
35
|
+
if line_count > 0:
|
|
36
|
+
paste_delay = min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
|
|
37
|
+
else:
|
|
38
|
+
paste_delay = 0.05
|
|
39
|
+
if agent_type == "codex":
|
|
40
|
+
return max(CODEX_PRE_ENTER_DELAY, paste_delay)
|
|
41
|
+
return paste_delay
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _send_prompt_for_agent(backend, session, text: str, agent_type: str) -> None:
|
|
45
|
+
"""Send a prompt through the active terminal backend."""
|
|
46
|
+
if isinstance(backend, ItermBackend):
|
|
47
|
+
await backend.send_prompt_for_agent(
|
|
48
|
+
session,
|
|
49
|
+
text,
|
|
50
|
+
agent_type=agent_type,
|
|
51
|
+
submit=True,
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
await backend.send_text(session, text)
|
|
55
|
+
await asyncio.sleep(_compute_prompt_delay(text, agent_type))
|
|
56
|
+
await backend.send_key(session, "enter")
|
|
57
|
+
|
|
58
|
+
|
|
30
59
|
async def _wait_for_sessions_idle(
|
|
31
60
|
sessions: list[tuple[str, object]],
|
|
32
61
|
mode: str,
|
|
@@ -136,6 +165,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
136
165
|
"""
|
|
137
166
|
app_ctx = ctx.request_context.lifespan_context
|
|
138
167
|
registry = app_ctx.registry
|
|
168
|
+
backend = app_ctx.terminal_backend
|
|
139
169
|
|
|
140
170
|
# Validate wait_mode
|
|
141
171
|
if wait_mode not in ("none", "any", "all"):
|
|
@@ -199,11 +229,11 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
199
229
|
|
|
200
230
|
# Send the message using agent-specific input handling.
|
|
201
231
|
# Codex needs a longer pre-Enter delay than Claude.
|
|
202
|
-
await
|
|
203
|
-
|
|
232
|
+
await _send_prompt_for_agent(
|
|
233
|
+
backend,
|
|
234
|
+
session.terminal_session,
|
|
204
235
|
message_with_hint,
|
|
205
|
-
|
|
206
|
-
submit=True,
|
|
236
|
+
session.agent_type,
|
|
207
237
|
)
|
|
208
238
|
|
|
209
239
|
return (sid, {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Poll worker changes tool.
|
|
3
|
+
|
|
4
|
+
Provides poll_worker_changes for reading worker event log updates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
13
|
+
from mcp.server.session import ServerSession
|
|
14
|
+
|
|
15
|
+
from claude_team import events as events_module
|
|
16
|
+
from claude_team.events import WorkerEvent
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from ..server import AppContext
|
|
20
|
+
|
|
21
|
+
from ..utils import error_response
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Parse ISO timestamps for query filtering and event handling.
|
|
25
|
+
def _parse_iso_timestamp(value: str) -> datetime | None:
|
|
26
|
+
value = value.strip()
|
|
27
|
+
if not value:
|
|
28
|
+
return None
|
|
29
|
+
# Normalize Zulu timestamps for fromisoformat.
|
|
30
|
+
if value.endswith("Z"):
|
|
31
|
+
value = value[:-1] + "+00:00"
|
|
32
|
+
try:
|
|
33
|
+
parsed = datetime.fromisoformat(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
# Default to UTC when no timezone is provided.
|
|
37
|
+
if parsed.tzinfo is None:
|
|
38
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
39
|
+
return parsed.astimezone(timezone.utc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Convert a WorkerEvent into a JSON-serializable payload.
|
|
43
|
+
def _serialize_event(event: WorkerEvent) -> dict:
|
|
44
|
+
return {
|
|
45
|
+
"ts": event.ts,
|
|
46
|
+
"type": event.type,
|
|
47
|
+
"worker_id": event.worker_id,
|
|
48
|
+
"data": event.data,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Extract a worker display name from event data.
|
|
53
|
+
def _event_name(event: WorkerEvent) -> str:
|
|
54
|
+
data = event.data or {}
|
|
55
|
+
for key in ("name", "worker_name", "session_name"):
|
|
56
|
+
value = data.get(key)
|
|
57
|
+
if value:
|
|
58
|
+
return str(value)
|
|
59
|
+
return event.worker_id or "unknown"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Extract a project identifier from event data.
|
|
63
|
+
def _event_project(event: WorkerEvent) -> str | None:
|
|
64
|
+
data = event.data or {}
|
|
65
|
+
for key in ("project", "project_path"):
|
|
66
|
+
value = data.get(key)
|
|
67
|
+
if value:
|
|
68
|
+
return str(value)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Extract a bead/issue reference from event data.
|
|
73
|
+
def _event_bead(event: WorkerEvent) -> str | None:
|
|
74
|
+
data = event.data or {}
|
|
75
|
+
for key in ("bead", "issue", "issue_id"):
|
|
76
|
+
value = data.get(key)
|
|
77
|
+
if value:
|
|
78
|
+
return str(value)
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Compute duration in minutes for a closed worker event.
|
|
83
|
+
def _duration_minutes(
|
|
84
|
+
event: WorkerEvent,
|
|
85
|
+
started_at: dict[str, datetime],
|
|
86
|
+
) -> int:
|
|
87
|
+
data = event.data or {}
|
|
88
|
+
# Use explicit duration fields when provided by the poller.
|
|
89
|
+
duration = data.get("duration_min")
|
|
90
|
+
if duration is not None:
|
|
91
|
+
try:
|
|
92
|
+
return max(0, int(duration))
|
|
93
|
+
except (TypeError, ValueError):
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
# Convert seconds to minutes when available.
|
|
97
|
+
duration_seconds = data.get("duration_seconds") or data.get("duration_sec")
|
|
98
|
+
if duration_seconds is not None:
|
|
99
|
+
try:
|
|
100
|
+
return max(0, int(float(duration_seconds) / 60))
|
|
101
|
+
except (TypeError, ValueError):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
# Fall back to timestamps if we can derive both endpoints.
|
|
105
|
+
started_raw = data.get("started_at") or data.get("start_ts") or data.get("started_ts")
|
|
106
|
+
started_ts = _parse_iso_timestamp(str(started_raw)) if started_raw else None
|
|
107
|
+
if not started_ts and event.worker_id:
|
|
108
|
+
started_ts = started_at.get(event.worker_id)
|
|
109
|
+
|
|
110
|
+
end_ts = _parse_iso_timestamp(event.ts) if event.ts else None
|
|
111
|
+
if started_ts and end_ts:
|
|
112
|
+
return max(0, int((end_ts - started_ts).total_seconds() / 60))
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def register_tools(mcp: FastMCP) -> None:
|
|
117
|
+
"""Register poll_worker_changes tool on the MCP server."""
|
|
118
|
+
|
|
119
|
+
@mcp.tool()
|
|
120
|
+
async def poll_worker_changes(
|
|
121
|
+
ctx: Context[ServerSession, "AppContext"],
|
|
122
|
+
since: str | None = None,
|
|
123
|
+
stale_threshold_minutes: int = 20,
|
|
124
|
+
include_snapshots: bool = False,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""
|
|
127
|
+
Poll worker event changes since a timestamp.
|
|
128
|
+
|
|
129
|
+
Reads the worker events log, summarizes started/completed/stuck workers,
|
|
130
|
+
and returns current idle/active counts.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
since: ISO timestamp to filter events from (inclusive), or None for latest.
|
|
134
|
+
stale_threshold_minutes: Minutes without activity before a worker is marked stuck.
|
|
135
|
+
include_snapshots: Whether to include snapshot events in the response.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Dict with:
|
|
139
|
+
- events: List of worker events since timestamp (filtered by include_snapshots)
|
|
140
|
+
- summary: started/completed/stuck worker summaries
|
|
141
|
+
- active_count: Count of active (non-idle) workers
|
|
142
|
+
- idle_count: Count of idle workers
|
|
143
|
+
- poll_ts: Timestamp when poll was generated
|
|
144
|
+
"""
|
|
145
|
+
app_ctx = ctx.request_context.lifespan_context
|
|
146
|
+
registry = app_ctx.registry
|
|
147
|
+
|
|
148
|
+
# Validate inputs before reading the log.
|
|
149
|
+
if stale_threshold_minutes <= 0:
|
|
150
|
+
return error_response(
|
|
151
|
+
"stale_threshold_minutes must be greater than 0",
|
|
152
|
+
hint="Use a value like 20 to detect stuck workers",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
parsed_since = None
|
|
156
|
+
if since is not None and since.strip():
|
|
157
|
+
parsed_since = _parse_iso_timestamp(since)
|
|
158
|
+
if parsed_since is None:
|
|
159
|
+
return error_response(
|
|
160
|
+
f"Invalid since timestamp: {since}",
|
|
161
|
+
hint="Use ISO format like 2026-01-27T11:40:00Z",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Read recent events from the log (capped by events module defaults).
|
|
165
|
+
events = events_module.read_events_since(parsed_since)
|
|
166
|
+
|
|
167
|
+
# Optionally drop snapshot events to keep responses lighter.
|
|
168
|
+
if not include_snapshots:
|
|
169
|
+
events = [event for event in events if event.type != "snapshot"]
|
|
170
|
+
|
|
171
|
+
# Track start times to estimate durations for closures.
|
|
172
|
+
started_at: dict[str, datetime] = {}
|
|
173
|
+
for event in events:
|
|
174
|
+
if event.type == "worker_started" and event.worker_id:
|
|
175
|
+
ts = _parse_iso_timestamp(event.ts)
|
|
176
|
+
if ts:
|
|
177
|
+
started_at[event.worker_id] = ts
|
|
178
|
+
|
|
179
|
+
# Build summary lists from event stream.
|
|
180
|
+
started: list[dict] = []
|
|
181
|
+
completed: list[dict] = []
|
|
182
|
+
for event in events:
|
|
183
|
+
if event.type == "worker_started":
|
|
184
|
+
started.append({
|
|
185
|
+
"name": _event_name(event),
|
|
186
|
+
"project": _event_project(event),
|
|
187
|
+
})
|
|
188
|
+
elif event.type == "worker_closed":
|
|
189
|
+
completed.append({
|
|
190
|
+
"name": _event_name(event),
|
|
191
|
+
"bead": _event_bead(event),
|
|
192
|
+
"duration_min": _duration_minutes(event, started_at),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
# Compute current idle/active counts and detect stuck workers.
|
|
196
|
+
stuck: list[dict] = []
|
|
197
|
+
idle_count = 0
|
|
198
|
+
active_count = 0
|
|
199
|
+
now = datetime.now()
|
|
200
|
+
threshold = stale_threshold_minutes
|
|
201
|
+
for session in registry.list_all():
|
|
202
|
+
is_idle = session.is_idle()
|
|
203
|
+
if is_idle:
|
|
204
|
+
idle_count += 1
|
|
205
|
+
else:
|
|
206
|
+
active_count += 1
|
|
207
|
+
|
|
208
|
+
inactive_minutes = int((now - session.last_activity).total_seconds() / 60)
|
|
209
|
+
if not is_idle and inactive_minutes >= threshold:
|
|
210
|
+
stuck.append({
|
|
211
|
+
"name": session.name or session.session_id,
|
|
212
|
+
"inactive_minutes": inactive_minutes,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
poll_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"events": [_serialize_event(event) for event in events],
|
|
219
|
+
"summary": {
|
|
220
|
+
"completed": completed,
|
|
221
|
+
"stuck": stuck,
|
|
222
|
+
"started": started,
|
|
223
|
+
},
|
|
224
|
+
"active_count": active_count,
|
|
225
|
+
"idle_count": idle_count,
|
|
226
|
+
"poll_ts": poll_ts,
|
|
227
|
+
}
|