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
claude_team/poller.py ADDED
@@ -0,0 +1,245 @@
1
+ """Background poller for worker state snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import time
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+ from typing import Literal, Protocol
12
+
13
+ from . import events
14
+ from .idle_detection import detect_worker_idle
15
+
16
+ logger = logging.getLogger("claude-team-poller")
17
+
18
+ WorkerState = Literal["idle", "active"]
19
+
20
+
21
+ # Minimal registry interface used by WorkerPoller.
22
+ class _RegistryLike(Protocol):
23
+ def list_all(self) -> list["_SessionLike"]:
24
+ ...
25
+
26
+
27
+ # Minimal session interface used by WorkerPoller.
28
+ class _SessionLike(Protocol):
29
+ session_id: str
30
+ agent_type: Literal["claude", "codex"]
31
+ project_path: str
32
+ claude_session_id: str | None
33
+ output_path: Path | None
34
+ message_count: int | None
35
+ last_message_count: int | None
36
+ last_message_timestamp: float | None
37
+ pid: int | None
38
+ is_idle: bool
39
+
40
+ def to_dict(self) -> dict:
41
+ ...
42
+
43
+
44
+ # Snapshot of a worker at a point in time.
45
+ @dataclass(frozen=True)
46
+ class _WorkerSnapshot:
47
+ session_id: str
48
+ state: WorkerState
49
+ info: dict
50
+
51
+
52
+ def _isoformat_zulu(value: datetime) -> str:
53
+ # Format timestamps with a Z suffix for UTC.
54
+ return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
+
56
+
57
+ def _sanitize_for_json(obj: object) -> object:
58
+ # Recursively sanitize an object for JSON serialization.
59
+ # Removes non-serializable types like asyncio Futures, methods, etc.
60
+ if obj is None or isinstance(obj, (bool, int, float, str)):
61
+ return obj
62
+ if callable(obj):
63
+ # Skip methods, functions, lambdas
64
+ return None
65
+ if isinstance(obj, dict):
66
+ return {str(k): _sanitize_for_json(v) for k, v in obj.items()}
67
+ if isinstance(obj, (list, tuple)):
68
+ return [_sanitize_for_json(item) for item in obj]
69
+ if isinstance(obj, Path):
70
+ return str(obj)
71
+ if isinstance(obj, datetime):
72
+ return obj.isoformat()
73
+ # For any other type, try to convert to string, else skip
74
+ try:
75
+ return str(obj)
76
+ except Exception:
77
+ return None
78
+
79
+
80
+ def _build_snapshot(registry: _RegistryLike) -> dict[str, _WorkerSnapshot]:
81
+ # Capture current worker states from the registry.
82
+ snapshots: dict[str, _WorkerSnapshot] = {}
83
+ for session in registry.list_all():
84
+ info = _sanitize_for_json(session.to_dict())
85
+ is_idle, _ = detect_worker_idle(session, idle_threshold_seconds=300)
86
+ info["is_idle"] = is_idle
87
+ state: WorkerState = "idle" if is_idle else "active"
88
+ snapshots[session.session_id] = _WorkerSnapshot(session.session_id, state, info)
89
+ return snapshots
90
+
91
+
92
+ def _snapshot_payload(snapshots: dict[str, _WorkerSnapshot]) -> dict:
93
+ # Build a full snapshot payload for persistence.
94
+ workers = []
95
+ for snapshot in snapshots.values():
96
+ payload = dict(snapshot.info)
97
+ payload["state"] = snapshot.state
98
+ workers.append(payload)
99
+ return {"count": len(workers), "workers": workers}
100
+
101
+
102
+ def _transition_payload(snapshot: _WorkerSnapshot, previous_state: WorkerState | None) -> dict:
103
+ # Build transition data payload for a worker event.
104
+ payload = dict(snapshot.info)
105
+ payload["state"] = snapshot.state
106
+ payload["previous_state"] = previous_state
107
+ return payload
108
+
109
+
110
+ def _closed_payload(snapshot: _WorkerSnapshot) -> dict:
111
+ # Build payload for a worker_closed event using the last known state.
112
+ payload = dict(snapshot.info)
113
+ payload["state"] = "closed"
114
+ payload["previous_state"] = snapshot.state
115
+ return payload
116
+
117
+
118
+ def _build_transition_events(
119
+ previous: dict[str, _WorkerSnapshot],
120
+ current: dict[str, _WorkerSnapshot],
121
+ timestamp: str,
122
+ ) -> list[events.WorkerEvent]:
123
+ # Compare snapshot sets and emit lifecycle transition events.
124
+ results: list[events.WorkerEvent] = []
125
+ previous_ids = set(previous)
126
+ current_ids = set(current)
127
+
128
+ # New sessions -> worker_started events.
129
+ for session_id in current_ids - previous_ids:
130
+ snapshot = current[session_id]
131
+ results.append(events.WorkerEvent(
132
+ ts=timestamp,
133
+ type="worker_started",
134
+ worker_id=session_id,
135
+ data=_transition_payload(snapshot, None),
136
+ ))
137
+
138
+ # Removed sessions -> worker_closed events.
139
+ for session_id in previous_ids - current_ids:
140
+ snapshot = previous[session_id]
141
+ results.append(events.WorkerEvent(
142
+ ts=timestamp,
143
+ type="worker_closed",
144
+ worker_id=session_id,
145
+ data=_closed_payload(snapshot),
146
+ ))
147
+
148
+ # Existing sessions -> idle/active transitions.
149
+ for session_id in previous_ids & current_ids:
150
+ before = previous[session_id]
151
+ after = current[session_id]
152
+ if before.state == after.state:
153
+ continue
154
+ event_type = "worker_idle" if after.state == "idle" else "worker_active"
155
+ results.append(events.WorkerEvent(
156
+ ts=timestamp,
157
+ type=event_type,
158
+ worker_id=session_id,
159
+ data=_transition_payload(after, before.state),
160
+ ))
161
+
162
+ return results
163
+
164
+
165
+ class WorkerPoller:
166
+ """Background poller that snapshots worker state and logs transitions."""
167
+
168
+ def __init__(
169
+ self,
170
+ registry: _RegistryLike,
171
+ poll_interval_seconds: int = 60,
172
+ snapshot_interval_seconds: int = 300,
173
+ ) -> None:
174
+ """Initialize the poller with registry and polling cadence."""
175
+ self._registry = registry
176
+ self._poll_interval_seconds = poll_interval_seconds
177
+ self._snapshot_interval_seconds = snapshot_interval_seconds
178
+ self._stop_event = asyncio.Event()
179
+ self._task: asyncio.Task | None = None
180
+ self._last_snapshot: dict[str, _WorkerSnapshot] = {}
181
+ self._last_snapshot_event_at: float | None = None
182
+
183
+ def start(self) -> None:
184
+ """Start the background polling task."""
185
+ if self._task and not self._task.done():
186
+ return
187
+ self._stop_event.clear()
188
+ self._task = asyncio.create_task(self._run(), name="worker-poller")
189
+
190
+ async def stop(self) -> None:
191
+ """Stop the background polling task."""
192
+ if not self._task:
193
+ return
194
+ self._stop_event.set()
195
+ await self._task
196
+ self._task = None
197
+
198
+ async def _run(self) -> None:
199
+ # Poll until stop is requested, logging events along the way.
200
+ while not self._stop_event.is_set():
201
+ try:
202
+ self._poll_once()
203
+ except Exception as exc: # pragma: no cover - defensive logging
204
+ logger.exception("Worker poller failed: %s", exc)
205
+ await self._wait_for_next_tick()
206
+
207
+ async def _wait_for_next_tick(self) -> None:
208
+ # Wait for either the next poll interval or a stop request.
209
+ try:
210
+ await asyncio.wait_for(self._stop_event.wait(), timeout=self._poll_interval_seconds)
211
+ except asyncio.TimeoutError:
212
+ return
213
+
214
+ def _poll_once(self) -> None:
215
+ # Capture a snapshot, diff it, and persist any resulting events.
216
+ # Timestamp both for events and snapshot cadence.
217
+ now_iso = _isoformat_zulu(datetime.now(timezone.utc))
218
+ now_monotonic = time.monotonic()
219
+ # Snapshot current registry and compute transitions.
220
+ current_snapshot = _build_snapshot(self._registry)
221
+ transitions = _build_transition_events(self._last_snapshot, current_snapshot, now_iso)
222
+
223
+ # Emit periodic full snapshot for recovery.
224
+ if self._should_emit_snapshot(now_monotonic):
225
+ transitions.append(events.WorkerEvent(
226
+ ts=now_iso,
227
+ type="snapshot",
228
+ worker_id=None,
229
+ data=_snapshot_payload(current_snapshot),
230
+ ))
231
+ self._last_snapshot_event_at = now_monotonic
232
+
233
+ # Persist any events in a single batch.
234
+ if transitions:
235
+ events.append_events(transitions)
236
+
237
+ # Update the in-memory snapshot for the next diff.
238
+ self._last_snapshot = current_snapshot
239
+
240
+ def _should_emit_snapshot(self, now_monotonic: float) -> bool:
241
+ # Decide whether it's time to emit a full snapshot event.
242
+ last = self._last_snapshot_event_at
243
+ if last is None:
244
+ return True
245
+ return (now_monotonic - last) >= self._snapshot_interval_seconds
@@ -6,15 +6,17 @@ This allows claude-team to orchestrate multiple agent types through a unified in
6
6
  """
7
7
 
8
8
  from .base import AgentCLI
9
- from .claude import ClaudeCLI, claude_cli
10
- from .codex import CodexCLI, codex_cli
9
+ from .claude import ClaudeCLI, claude_cli, get_claude_command
10
+ from .codex import CodexCLI, codex_cli, get_codex_command
11
11
 
12
12
  __all__ = [
13
13
  "AgentCLI",
14
14
  "ClaudeCLI",
15
15
  "claude_cli",
16
+ "get_claude_command",
16
17
  "CodexCLI",
17
18
  "codex_cli",
19
+ "get_codex_command",
18
20
  "get_cli_backend",
19
21
  ]
20
22
 
@@ -10,6 +10,45 @@ from typing import Literal
10
10
 
11
11
  from .base import AgentCLI
12
12
 
13
+ # Built-in default command.
14
+ _DEFAULT_COMMAND = "claude"
15
+
16
+ # Environment variable for command override (takes highest precedence).
17
+ _ENV_VAR = "CLAUDE_TEAM_COMMAND"
18
+
19
+
20
+ def get_claude_command() -> str:
21
+ """
22
+ Get the Claude CLI command with precedence: env var > config > default.
23
+
24
+ Resolution order:
25
+ 1. CLAUDE_TEAM_COMMAND environment variable (for override)
26
+ 2. Config file commands.claude setting
27
+ 3. Built-in default "claude"
28
+
29
+ Returns:
30
+ The command to use for Claude CLI
31
+ """
32
+ # Environment variable takes highest precedence (for override).
33
+ env_val = os.environ.get(_ENV_VAR)
34
+ if env_val:
35
+ return env_val
36
+
37
+ # Try config file next.
38
+ # Import here to avoid circular imports and lazy-load config.
39
+ try:
40
+ from ..config import ConfigError, load_config
41
+
42
+ config = load_config()
43
+ except ConfigError:
44
+ return _DEFAULT_COMMAND
45
+
46
+ if config.commands.claude:
47
+ return config.commands.claude
48
+
49
+ # Fall back to built-in default.
50
+ return _DEFAULT_COMMAND
51
+
13
52
 
14
53
  class ClaudeCLI(AgentCLI):
15
54
  """
@@ -31,10 +70,12 @@ class ClaudeCLI(AgentCLI):
31
70
  """
32
71
  Return the Claude CLI command.
33
72
 
34
- Respects CLAUDE_TEAM_COMMAND environment variable for overrides
35
- (e.g., "happy" wrapper).
73
+ Resolution order:
74
+ 1. CLAUDE_TEAM_COMMAND environment variable (for override)
75
+ 2. Config file commands.claude setting
76
+ 3. Built-in default "claude"
36
77
  """
37
- return os.environ.get("CLAUDE_TEAM_COMMAND", "claude")
78
+ return get_claude_command()
38
79
 
39
80
  def build_args(
40
81
  self,
@@ -102,8 +143,7 @@ class ClaudeCLI(AgentCLI):
102
143
 
103
144
  def _is_default_command(self) -> bool:
104
145
  """Check if using the default 'claude' command (not a custom wrapper)."""
105
- cmd = os.environ.get("CLAUDE_TEAM_COMMAND", "claude")
106
- return cmd == "claude"
146
+ return get_claude_command() == _DEFAULT_COMMAND
107
147
 
108
148
 
109
149
  # Singleton instance for convenience
@@ -12,6 +12,45 @@ from typing import Literal
12
12
 
13
13
  from .base import AgentCLI
14
14
 
15
+ # Built-in default command.
16
+ _DEFAULT_COMMAND = "codex"
17
+
18
+ # Environment variable for command override (takes highest precedence).
19
+ _ENV_VAR = "CLAUDE_TEAM_CODEX_COMMAND"
20
+
21
+
22
+ def get_codex_command() -> str:
23
+ """
24
+ Get the Codex CLI command with precedence: env var > config > default.
25
+
26
+ Resolution order:
27
+ 1. CLAUDE_TEAM_CODEX_COMMAND environment variable (for override)
28
+ 2. Config file commands.codex setting
29
+ 3. Built-in default "codex"
30
+
31
+ Returns:
32
+ The command to use for Codex CLI
33
+ """
34
+ # Environment variable takes highest precedence (for override).
35
+ env_val = os.environ.get(_ENV_VAR)
36
+ if env_val:
37
+ return env_val
38
+
39
+ # Try config file next.
40
+ # Import here to avoid circular imports and lazy-load config.
41
+ try:
42
+ from ..config import ConfigError, load_config
43
+
44
+ config = load_config()
45
+ except ConfigError:
46
+ return _DEFAULT_COMMAND
47
+
48
+ if config.commands.codex:
49
+ return config.commands.codex
50
+
51
+ # Fall back to built-in default.
52
+ return _DEFAULT_COMMAND
53
+
15
54
 
16
55
  class CodexCLI(AgentCLI):
17
56
  """
@@ -35,10 +74,12 @@ class CodexCLI(AgentCLI):
35
74
  """
36
75
  Return the Codex CLI command.
37
76
 
38
- Respects CLAUDE_TEAM_CODEX_COMMAND environment variable for overrides
39
- (e.g., "happy codex" wrapper).
77
+ Resolution order:
78
+ 1. CLAUDE_TEAM_CODEX_COMMAND environment variable (for override)
79
+ 2. Config file commands.codex setting
80
+ 3. Built-in default "codex"
40
81
  """
41
- return os.environ.get("CLAUDE_TEAM_CODEX_COMMAND", "codex")
82
+ return get_codex_command()
42
83
 
43
84
  def build_args(
44
85
  self,