claude-team-mcp 0.4.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 (42) hide show
  1. claude_team_mcp/__init__.py +24 -0
  2. claude_team_mcp/__main__.py +8 -0
  3. claude_team_mcp/cli_backends/__init__.py +44 -0
  4. claude_team_mcp/cli_backends/base.py +132 -0
  5. claude_team_mcp/cli_backends/claude.py +110 -0
  6. claude_team_mcp/cli_backends/codex.py +110 -0
  7. claude_team_mcp/colors.py +108 -0
  8. claude_team_mcp/formatting.py +120 -0
  9. claude_team_mcp/idle_detection.py +488 -0
  10. claude_team_mcp/iterm_utils.py +1119 -0
  11. claude_team_mcp/names.py +427 -0
  12. claude_team_mcp/profile.py +364 -0
  13. claude_team_mcp/registry.py +426 -0
  14. claude_team_mcp/schemas/__init__.py +5 -0
  15. claude_team_mcp/schemas/codex.py +267 -0
  16. claude_team_mcp/server.py +390 -0
  17. claude_team_mcp/session_state.py +1058 -0
  18. claude_team_mcp/subprocess_cache.py +119 -0
  19. claude_team_mcp/tools/__init__.py +52 -0
  20. claude_team_mcp/tools/adopt_worker.py +122 -0
  21. claude_team_mcp/tools/annotate_worker.py +57 -0
  22. claude_team_mcp/tools/bd_help.py +42 -0
  23. claude_team_mcp/tools/check_idle_workers.py +98 -0
  24. claude_team_mcp/tools/close_workers.py +194 -0
  25. claude_team_mcp/tools/discover_workers.py +129 -0
  26. claude_team_mcp/tools/examine_worker.py +56 -0
  27. claude_team_mcp/tools/list_workers.py +76 -0
  28. claude_team_mcp/tools/list_worktrees.py +106 -0
  29. claude_team_mcp/tools/message_workers.py +311 -0
  30. claude_team_mcp/tools/read_worker_logs.py +158 -0
  31. claude_team_mcp/tools/spawn_workers.py +634 -0
  32. claude_team_mcp/tools/wait_idle_workers.py +148 -0
  33. claude_team_mcp/utils/__init__.py +17 -0
  34. claude_team_mcp/utils/constants.py +87 -0
  35. claude_team_mcp/utils/errors.py +87 -0
  36. claude_team_mcp/utils/worktree_detection.py +79 -0
  37. claude_team_mcp/worker_prompt.py +350 -0
  38. claude_team_mcp/worktree.py +532 -0
  39. claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
  40. claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
  41. claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
  42. claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,488 @@
1
+ """
2
+ Idle Detection for Claude Team Workers
3
+
4
+ Supports two detection modes:
5
+
6
+ 1. Claude Code (Stop Hook Detection):
7
+ Workers are spawned with a Stop hook that fires when Claude finishes responding.
8
+ The hook embeds a session ID marker in the JSONL - if it fired with no subsequent
9
+ messages, the worker is idle.
10
+ Binary state model:
11
+ - Idle: Stop hook fired, no messages after it
12
+ - Working: Either no stop hook yet, or messages exist after the last one
13
+
14
+ 2. Codex (Session File Polling):
15
+ Codex writes session files to ~/.codex/sessions/YYYY/MM/DD/.
16
+ We poll these files for agent_message events which indicate the agent
17
+ has finished responding. The session file name contains the thread_id.
18
+ Binary state model:
19
+ - Idle: Last response_item with agent_message type exists, no subsequent user_message
20
+ - Working: No agent_message yet, or user_message exists after the last agent_message
21
+ """
22
+
23
+ import asyncio
24
+ import json
25
+ import logging
26
+ import time
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ import msgspec
31
+
32
+ from .schemas.codex import ThreadStarted, TurnCompleted, TurnFailed, TurnStarted, decode_event
33
+ from .session_state import is_session_stopped
34
+
35
+ logger = logging.getLogger("claude-team-mcp")
36
+
37
+ # Path to Codex session files
38
+ CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
39
+
40
+
41
+ def find_codex_session_file(
42
+ thread_id: str | None = None,
43
+ max_age_seconds: int = 300,
44
+ ) -> Path | None:
45
+ """
46
+ Find a Codex session file in ~/.codex/sessions/.
47
+
48
+ Searches for session files matching the given thread_id, or returns
49
+ the most recent session file if no thread_id is specified.
50
+
51
+ Session files are named: rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>.jsonl
52
+
53
+ Args:
54
+ thread_id: Optional thread ID to search for. If None, returns most recent.
55
+ max_age_seconds: Only consider files modified within this time (default 5 min)
56
+
57
+ Returns:
58
+ Path to the matching session file, or None if not found
59
+ """
60
+ if not CODEX_SESSIONS_DIR.exists():
61
+ return None
62
+
63
+ now = time.time()
64
+ cutoff = now - max_age_seconds
65
+
66
+ # Search recent date directories (today and yesterday)
67
+ date_dirs: list[Path] = []
68
+ for year_dir in sorted(CODEX_SESSIONS_DIR.iterdir(), reverse=True):
69
+ if not year_dir.is_dir():
70
+ continue
71
+ for month_dir in sorted(year_dir.iterdir(), reverse=True):
72
+ if not month_dir.is_dir():
73
+ continue
74
+ for day_dir in sorted(month_dir.iterdir(), reverse=True):
75
+ if not day_dir.is_dir():
76
+ continue
77
+ date_dirs.append(day_dir)
78
+ # Limit to recent 3 days of directories
79
+ if len(date_dirs) >= 3:
80
+ break
81
+ if len(date_dirs) >= 3:
82
+ break
83
+ if len(date_dirs) >= 3:
84
+ break
85
+
86
+ candidates: list[tuple[float, Path]] = []
87
+
88
+ for date_dir in date_dirs:
89
+ for jsonl_file in date_dir.glob("rollout-*.jsonl"):
90
+ # Check file age
91
+ try:
92
+ mtime = jsonl_file.stat().st_mtime
93
+ if mtime < cutoff:
94
+ continue
95
+
96
+ # If thread_id specified, check if it's in the filename
97
+ if thread_id:
98
+ if thread_id in jsonl_file.name:
99
+ return jsonl_file
100
+ else:
101
+ candidates.append((mtime, jsonl_file))
102
+
103
+ except OSError:
104
+ continue
105
+
106
+ # If no thread_id specified, return most recent file
107
+ if candidates and not thread_id:
108
+ candidates.sort(reverse=True) # Sort by mtime descending
109
+ return candidates[0][1]
110
+
111
+ return None
112
+
113
+
114
+ def get_codex_thread_id_from_session_file(jsonl_path: Path) -> str | None:
115
+ """
116
+ Extract the thread_id from a Codex session file name.
117
+
118
+ Session files are named: rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>.jsonl
119
+ The thread_id is the last component before .jsonl.
120
+
121
+ Args:
122
+ jsonl_path: Path to the Codex session file
123
+
124
+ Returns:
125
+ The thread_id string if found, None otherwise
126
+ """
127
+ name = jsonl_path.stem # Remove .jsonl
128
+ # Format: rollout-2026-01-11T15-14-58-019baf57-64cb-7cc1-96bf-1d41751e40fc
129
+ # The thread_id is a UUID after the timestamp
130
+ parts = name.split("-")
131
+ if len(parts) >= 8:
132
+ # Thread ID is the last 5 parts (UUID format: 8-4-4-4-12 hex chars)
133
+ # But looking at actual names, it appears to be everything after the timestamp
134
+ # rollout-YYYY-MM-DDTHH-MM-SS-<thread_id>
135
+ # We can extract from session_meta in the file instead
136
+ pass
137
+
138
+ # More reliable: extract from session_meta in the file
139
+ try:
140
+ with open(jsonl_path, "rb") as f:
141
+ first_line = f.readline()
142
+ if first_line:
143
+ data = json.loads(first_line)
144
+ if data.get("type") == "session_meta":
145
+ return data.get("payload", {}).get("id")
146
+ except (OSError, json.JSONDecodeError):
147
+ pass
148
+
149
+ return None
150
+
151
+
152
+ def get_codex_thread_id(jsonl_path: Path) -> str | None:
153
+ """
154
+ Extract the thread_id from a Codex session's JSONL output.
155
+
156
+ Parses the JSONL file looking for a ThreadStarted event, which contains
157
+ the thread_id needed for session resume commands.
158
+
159
+ The ThreadStarted event is typically near the beginning of the file,
160
+ but we read from the start to find it reliably.
161
+
162
+ Args:
163
+ jsonl_path: Path to the Codex JSONL output file
164
+
165
+ Returns:
166
+ The thread_id string if found, None otherwise
167
+ """
168
+ if not jsonl_path.exists():
169
+ return None
170
+
171
+ try:
172
+ # Read the first portion of the file (ThreadStarted is near the beginning)
173
+ # Limit read to first 10KB to avoid loading huge files
174
+ with open(jsonl_path, "rb") as f:
175
+ content = f.read(10000)
176
+
177
+ # Parse lines looking for ThreadStarted
178
+ lines = content.strip().split(b"\n")
179
+
180
+ for line in lines:
181
+ if not line.strip():
182
+ continue
183
+
184
+ try:
185
+ event = decode_event(line)
186
+
187
+ # Check for ThreadStarted which contains thread_id
188
+ if isinstance(event, ThreadStarted):
189
+ return event.thread_id
190
+
191
+ except Exception:
192
+ # Skip malformed lines (could be partial at end of read)
193
+ continue
194
+
195
+ # No ThreadStarted found
196
+ return None
197
+
198
+ except (OSError, IOError) as e:
199
+ logger.warning(f"Error reading Codex JSONL {jsonl_path}: {e}")
200
+ return None
201
+
202
+ # Default timeout for waiting operations (10 minutes)
203
+ DEFAULT_TIMEOUT = 600.0
204
+ DEFAULT_POLL_INTERVAL = 2.0
205
+
206
+
207
+ def is_idle(jsonl_path: Path, session_id: str) -> bool:
208
+ """
209
+ Check if a Claude Code session is idle (finished responding).
210
+
211
+ A session is idle if its Stop hook has fired and no messages
212
+ have been sent after it.
213
+
214
+ Args:
215
+ jsonl_path: Path to the session JSONL file
216
+ session_id: The session ID (matches marker in Stop hook)
217
+
218
+ Returns:
219
+ True if idle, False if working or file not found
220
+ """
221
+ if not jsonl_path.exists():
222
+ return False
223
+ return is_session_stopped(jsonl_path, session_id)
224
+
225
+
226
+ def is_codex_idle(jsonl_path: Path) -> bool:
227
+ """
228
+ Check if a Codex session is idle by parsing the session JSONL file.
229
+
230
+ For interactive mode session files (in ~/.codex/sessions/), idle detection
231
+ works by checking the last events in the file:
232
+ - If the last response_item has type "message" with role "assistant" or
233
+ has payload.type "agent_message", the agent has responded and is idle
234
+ - If there's a user_message after the last agent response, still working
235
+
236
+ For exec mode (legacy capture files), checks for TurnCompleted/TurnFailed events.
237
+
238
+ Args:
239
+ jsonl_path: Path to the Codex JSONL session file
240
+
241
+ Returns:
242
+ True if idle (agent responded), False if working or file not found
243
+ """
244
+ if not jsonl_path.exists():
245
+ return False
246
+
247
+ try:
248
+ file_size = jsonl_path.stat().st_size
249
+ if file_size == 0:
250
+ return False
251
+
252
+ # Read last 50KB for efficiency
253
+ read_size = min(file_size, 50000)
254
+ with open(jsonl_path, "rb") as f:
255
+ if file_size > read_size:
256
+ f.seek(file_size - read_size)
257
+ f.readline() # Skip partial first line
258
+ content = f.read()
259
+
260
+ lines = content.strip().split(b"\n")
261
+
262
+ # Track the last significant events
263
+ last_agent_response_idx = -1
264
+ last_user_message_idx = -1
265
+
266
+ for i, line in enumerate(lines):
267
+ if not line.strip():
268
+ continue
269
+
270
+ try:
271
+ data = json.loads(line)
272
+
273
+ # Check for interactive mode format (wrapped events)
274
+ if data.get("type") == "event_msg":
275
+ payload = data.get("payload", {})
276
+ payload_type = payload.get("type")
277
+ # agent_message indicates agent finished responding
278
+ if payload_type == "agent_message":
279
+ last_agent_response_idx = i
280
+ # user_message indicates new input
281
+ elif payload_type == "user_message":
282
+ last_user_message_idx = i
283
+
284
+ elif data.get("type") == "response_item":
285
+ payload = data.get("payload", {})
286
+ payload_type = payload.get("type")
287
+ role = payload.get("role")
288
+ # message with role=assistant indicates agent response
289
+ if payload_type == "message" and role == "assistant":
290
+ last_agent_response_idx = i
291
+ # message with role=user indicates user input
292
+ elif payload_type == "message" and role == "user":
293
+ last_user_message_idx = i
294
+ # agent_message type in payload
295
+ elif payload_type == "agent_message":
296
+ last_agent_response_idx = i
297
+
298
+ # Check for exec mode format (direct events)
299
+ else:
300
+ event_type = data.get("type")
301
+ if event_type in ("turn.completed", "turn.failed"):
302
+ last_agent_response_idx = i
303
+ elif event_type == "turn.started":
304
+ last_user_message_idx = i
305
+
306
+ except json.JSONDecodeError:
307
+ # Try msgspec for exec mode format
308
+ try:
309
+ event = decode_event(line)
310
+ if isinstance(event, (TurnCompleted, TurnFailed)):
311
+ last_agent_response_idx = i
312
+ elif isinstance(event, TurnStarted):
313
+ last_user_message_idx = i
314
+ except msgspec.DecodeError:
315
+ continue
316
+
317
+ # Idle if we have an agent response and no user message after it
318
+ if last_agent_response_idx >= 0:
319
+ return last_user_message_idx < last_agent_response_idx
320
+
321
+ return False
322
+
323
+ except (OSError, IOError) as e:
324
+ logger.warning(f"Error reading Codex JSONL {jsonl_path}: {e}")
325
+ return False
326
+
327
+
328
+ async def wait_for_idle(
329
+ jsonl_path: Path,
330
+ session_id: str,
331
+ timeout: float = DEFAULT_TIMEOUT,
332
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
333
+ ) -> dict:
334
+ """
335
+ Wait for a session to become idle.
336
+
337
+ Polls until the Stop hook fires or timeout is reached.
338
+
339
+ Args:
340
+ jsonl_path: Path to session JSONL file
341
+ session_id: The session ID to check
342
+ timeout: Maximum seconds to wait (default 600s / 10 min)
343
+ poll_interval: Seconds between checks
344
+
345
+ Returns:
346
+ Dict with {idle: bool, session_id: str, waited_seconds: float, timed_out: bool}
347
+ """
348
+ start = time.time()
349
+
350
+ while time.time() - start < timeout:
351
+ if is_idle(jsonl_path, session_id):
352
+ return {
353
+ "idle": True,
354
+ "session_id": session_id,
355
+ "waited_seconds": time.time() - start,
356
+ "timed_out": False,
357
+ }
358
+ await asyncio.sleep(poll_interval)
359
+
360
+ # Timeout
361
+ return {
362
+ "idle": False,
363
+ "session_id": session_id,
364
+ "waited_seconds": timeout,
365
+ "timed_out": True,
366
+ }
367
+
368
+
369
+ @dataclass
370
+ class SessionInfo:
371
+ """Info needed to check a session's idle state."""
372
+
373
+ jsonl_path: Path
374
+ session_id: str
375
+
376
+
377
+ async def wait_for_any_idle(
378
+ sessions: list[SessionInfo],
379
+ timeout: float = DEFAULT_TIMEOUT,
380
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
381
+ ) -> dict:
382
+ """
383
+ Wait for ANY session to become idle.
384
+
385
+ Returns as soon as the first session becomes idle.
386
+ Useful for pipeline patterns where you want to process results
387
+ as they become available.
388
+
389
+ Args:
390
+ sessions: List of SessionInfo to monitor
391
+ timeout: Maximum seconds to wait
392
+ poll_interval: Seconds between checks
393
+
394
+ Returns:
395
+ Dict with {
396
+ idle_session_id: str | None, # First session to become idle
397
+ idle: bool, # True if any session became idle
398
+ waited_seconds: float,
399
+ timed_out: bool,
400
+ }
401
+ """
402
+ start = time.time()
403
+
404
+ while time.time() - start < timeout:
405
+ for session in sessions:
406
+ if is_idle(session.jsonl_path, session.session_id):
407
+ return {
408
+ "idle_session_id": session.session_id,
409
+ "idle": True,
410
+ "waited_seconds": time.time() - start,
411
+ "timed_out": False,
412
+ }
413
+ await asyncio.sleep(poll_interval)
414
+
415
+ return {
416
+ "idle_session_id": None,
417
+ "idle": False,
418
+ "waited_seconds": timeout,
419
+ "timed_out": True,
420
+ }
421
+
422
+
423
+ async def wait_for_all_idle(
424
+ sessions: list[SessionInfo],
425
+ timeout: float = DEFAULT_TIMEOUT,
426
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
427
+ ) -> dict:
428
+ """
429
+ Wait for ALL sessions to become idle.
430
+
431
+ Returns when every session has become idle, or on timeout.
432
+ Useful for fan-out/fan-in patterns where you need all results
433
+ before proceeding.
434
+
435
+ Args:
436
+ sessions: List of SessionInfo to monitor
437
+ timeout: Maximum seconds to wait
438
+ poll_interval: Seconds between checks
439
+
440
+ Returns:
441
+ Dict with {
442
+ idle_session_ids: list[str], # Sessions that are idle
443
+ all_idle: bool, # True if all sessions became idle
444
+ waiting_on: list[str], # Sessions still working (if timed out)
445
+ waited_seconds: float,
446
+ timed_out: bool,
447
+ }
448
+ """
449
+ start = time.time()
450
+
451
+ while time.time() - start < timeout:
452
+ idle_sessions = []
453
+ working_sessions = []
454
+
455
+ for session in sessions:
456
+ if is_idle(session.jsonl_path, session.session_id):
457
+ idle_sessions.append(session.session_id)
458
+ else:
459
+ working_sessions.append(session.session_id)
460
+
461
+ if not working_sessions:
462
+ # All idle!
463
+ return {
464
+ "idle_session_ids": idle_sessions,
465
+ "all_idle": True,
466
+ "waiting_on": [],
467
+ "waited_seconds": time.time() - start,
468
+ "timed_out": False,
469
+ }
470
+
471
+ await asyncio.sleep(poll_interval)
472
+
473
+ # Timeout - return final state
474
+ idle_sessions = []
475
+ working_sessions = []
476
+ for session in sessions:
477
+ if is_idle(session.jsonl_path, session.session_id):
478
+ idle_sessions.append(session.session_id)
479
+ else:
480
+ working_sessions.append(session.session_id)
481
+
482
+ return {
483
+ "idle_session_ids": idle_sessions,
484
+ "all_idle": False,
485
+ "waiting_on": working_sessions,
486
+ "waited_seconds": timeout,
487
+ "timed_out": True,
488
+ }