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.
- claude_team/__init__.py +11 -0
- claude_team/events.py +501 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +164 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +49 -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 +683 -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 +254 -153
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +73 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
82
|
+
return get_codex_command()
|
|
42
83
|
|
|
43
84
|
def build_args(
|
|
44
85
|
self,
|