claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.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.
Files changed (35) hide show
  1. claude_team/__init__.py +11 -0
  2. claude_team/events.py +501 -0
  3. claude_team/idle_detection.py +173 -0
  4. claude_team/poller.py +245 -0
  5. claude_team_mcp/cli_backends/__init__.py +4 -2
  6. claude_team_mcp/cli_backends/claude.py +45 -5
  7. claude_team_mcp/cli_backends/codex.py +44 -3
  8. claude_team_mcp/config.py +350 -0
  9. claude_team_mcp/config_cli.py +263 -0
  10. claude_team_mcp/idle_detection.py +16 -3
  11. claude_team_mcp/issue_tracker/__init__.py +68 -3
  12. claude_team_mcp/iterm_utils.py +5 -73
  13. claude_team_mcp/registry.py +43 -26
  14. claude_team_mcp/server.py +164 -61
  15. claude_team_mcp/session_state.py +364 -2
  16. claude_team_mcp/terminal_backends/__init__.py +49 -0
  17. claude_team_mcp/terminal_backends/base.py +106 -0
  18. claude_team_mcp/terminal_backends/iterm.py +251 -0
  19. claude_team_mcp/terminal_backends/tmux.py +683 -0
  20. claude_team_mcp/tools/__init__.py +4 -2
  21. claude_team_mcp/tools/adopt_worker.py +89 -32
  22. claude_team_mcp/tools/close_workers.py +39 -10
  23. claude_team_mcp/tools/discover_workers.py +176 -32
  24. claude_team_mcp/tools/list_workers.py +29 -0
  25. claude_team_mcp/tools/message_workers.py +35 -5
  26. claude_team_mcp/tools/poll_worker_changes.py +227 -0
  27. claude_team_mcp/tools/spawn_workers.py +254 -153
  28. claude_team_mcp/tools/wait_idle_workers.py +1 -0
  29. claude_team_mcp/utils/errors.py +7 -3
  30. claude_team_mcp/worktree.py +73 -12
  31. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
  32. claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
  33. claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
  34. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
  35. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.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 send_prompt_for_agent
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 send_prompt_for_agent(
203
- session.iterm_session,
232
+ await _send_prompt_for_agent(
233
+ backend,
234
+ session.terminal_session,
204
235
  message_with_hint,
205
- agent_type=session.agent_type,
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
+ }