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.
@@ -0,0 +1,11 @@
1
+ """Core modules for the claude-team tooling."""
2
+
3
+ from .idle_detection import Worker, check_file_idle, detect_worker_idle, get_claude_jsonl_path, get_project_slug
4
+
5
+ __all__ = [
6
+ "Worker",
7
+ "check_file_idle",
8
+ "detect_worker_idle",
9
+ "get_claude_jsonl_path",
10
+ "get_project_slug",
11
+ ]
claude_team/events.py ADDED
@@ -0,0 +1,477 @@
1
+ """Event log persistence for worker lifecycle activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from datetime import datetime, timedelta, timezone
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ try:
13
+ import fcntl
14
+ except ImportError: # pragma: no cover - platform-specific
15
+ fcntl = None
16
+
17
+ try:
18
+ import msvcrt
19
+ except ImportError: # pragma: no cover - platform-specific
20
+ msvcrt = None
21
+
22
+
23
+ EventType = Literal[
24
+ "snapshot",
25
+ "worker_started",
26
+ "worker_idle",
27
+ "worker_active",
28
+ "worker_closed",
29
+ ]
30
+
31
+
32
+ def _int_env(name: str, default: int) -> int:
33
+ # Parse integer environment overrides with a safe fallback.
34
+ value = os.environ.get(name)
35
+ if value is None or value == "":
36
+ return default
37
+ try:
38
+ return int(value)
39
+ except ValueError:
40
+ return default
41
+
42
+
43
+ DEFAULT_ROTATION_MAX_SIZE_MB = _int_env("CLAUDE_TEAM_EVENTS_MAX_SIZE_MB", 1)
44
+ DEFAULT_ROTATION_RECENT_HOURS = _int_env("CLAUDE_TEAM_EVENTS_RECENT_HOURS", 24)
45
+
46
+
47
+ @dataclass
48
+ class WorkerEvent:
49
+ """Represents a persisted worker event."""
50
+
51
+ ts: str
52
+ type: EventType
53
+ worker_id: str | None
54
+ data: dict
55
+
56
+
57
+ def get_events_path() -> Path:
58
+ """Returns ~/.claude-team/events.jsonl, creating parent dir if needed."""
59
+ base_dir = Path.home() / ".claude-team"
60
+ base_dir.mkdir(parents=True, exist_ok=True)
61
+ return base_dir / "events.jsonl"
62
+
63
+
64
+ def append_event(event: WorkerEvent) -> None:
65
+ """Append single event to log file (atomic write with file locking)."""
66
+ append_events([event])
67
+
68
+
69
+ def _event_to_dict(event: WorkerEvent) -> dict:
70
+ """Convert WorkerEvent to dict without using asdict (avoids deepcopy issues)."""
71
+ return {
72
+ "ts": event.ts,
73
+ "type": event.type,
74
+ "worker_id": event.worker_id,
75
+ "data": event.data, # Already sanitized by caller
76
+ }
77
+
78
+
79
+ def append_events(events: list[WorkerEvent]) -> None:
80
+ """Append multiple events atomically."""
81
+ if not events:
82
+ return
83
+
84
+ path = get_events_path()
85
+ if not path.exists():
86
+ path.touch()
87
+ # Serialize upfront so the file write is a single, ordered block.
88
+ # Use _event_to_dict instead of asdict to avoid deepcopy pickle issues.
89
+ payloads = [json.dumps(_event_to_dict(event), ensure_ascii=False) for event in events]
90
+ block = "\n".join(payloads) + "\n"
91
+ event_ts = _latest_event_timestamp(events)
92
+
93
+ with path.open("r+", encoding="utf-8") as handle:
94
+ _lock_file(handle)
95
+ try:
96
+ _rotate_events_log_locked(
97
+ handle,
98
+ path,
99
+ current_ts=event_ts,
100
+ max_size_mb=DEFAULT_ROTATION_MAX_SIZE_MB,
101
+ recent_hours=DEFAULT_ROTATION_RECENT_HOURS,
102
+ )
103
+ # Hold the lock across the entire write and flush cycle.
104
+ handle.seek(0, os.SEEK_END)
105
+ handle.write(block)
106
+ handle.flush()
107
+ os.fsync(handle.fileno())
108
+ finally:
109
+ _unlock_file(handle)
110
+
111
+
112
+ def read_events_since(
113
+ since: datetime | None = None,
114
+ limit: int = 1000,
115
+ ) -> list[WorkerEvent]:
116
+ """Read events from log, optionally filtered by timestamp."""
117
+ if limit <= 0:
118
+ return []
119
+
120
+ path = get_events_path()
121
+ if not path.exists():
122
+ return []
123
+
124
+ normalized_since = _normalize_since(since)
125
+ events: list[WorkerEvent] = []
126
+
127
+ with path.open("r", encoding="utf-8") as handle:
128
+ # Stream the file so we don't load the entire log into memory.
129
+ for line in handle:
130
+ line = line.strip()
131
+ if not line:
132
+ continue
133
+
134
+ event = _parse_event(json.loads(line))
135
+ # Compare timestamps only when a filter is provided.
136
+ if normalized_since is not None:
137
+ event_ts = _parse_timestamp(event.ts)
138
+ if event_ts < normalized_since:
139
+ continue
140
+
141
+ events.append(event)
142
+ # Keep only the most recent events within the requested limit.
143
+ if len(events) > limit:
144
+ events.pop(0)
145
+
146
+ return events
147
+
148
+
149
+ def get_latest_snapshot() -> dict | None:
150
+ """Get most recent snapshot event for recovery."""
151
+ path = get_events_path()
152
+ if not path.exists():
153
+ return None
154
+
155
+ latest_snapshot: dict | None = None
156
+
157
+ with path.open("r", encoding="utf-8") as handle:
158
+ # Walk the log to track the latest snapshot without extra storage.
159
+ for line in handle:
160
+ line = line.strip()
161
+ if not line:
162
+ continue
163
+
164
+ event = _parse_event(json.loads(line))
165
+ if event.type == "snapshot":
166
+ latest_snapshot = event.data
167
+
168
+ return latest_snapshot
169
+
170
+
171
+ def rotate_events_log(
172
+ max_size_mb: int = DEFAULT_ROTATION_MAX_SIZE_MB,
173
+ recent_hours: int = DEFAULT_ROTATION_RECENT_HOURS,
174
+ now: datetime | None = None,
175
+ ) -> None:
176
+ """Rotate the log daily or by size, retaining active/recent workers."""
177
+ path = get_events_path()
178
+ if not path.exists():
179
+ return
180
+
181
+ current_ts = now or datetime.now(timezone.utc)
182
+
183
+ with path.open("r+", encoding="utf-8") as handle:
184
+ _lock_file(handle)
185
+ try:
186
+ _rotate_events_log_locked(
187
+ handle,
188
+ path,
189
+ current_ts=current_ts,
190
+ max_size_mb=max_size_mb,
191
+ recent_hours=recent_hours,
192
+ )
193
+ finally:
194
+ _unlock_file(handle)
195
+
196
+
197
+ def _rotate_events_log_locked(
198
+ handle,
199
+ path: Path,
200
+ current_ts: datetime,
201
+ max_size_mb: int,
202
+ recent_hours: int,
203
+ ) -> None:
204
+ # Rotate the log while holding the caller's lock.
205
+ if not _should_rotate(path, current_ts, max_size_mb):
206
+ return
207
+
208
+ rotation_day = _rotation_day(path, current_ts)
209
+ backup_path = _backup_path(path, rotation_day)
210
+
211
+ last_seen, last_state = _copy_and_collect_activity(handle, backup_path)
212
+ keep_ids = _select_workers_to_keep(last_seen, last_state, current_ts, recent_hours)
213
+ retained_lines = _filter_retained_events(handle, keep_ids)
214
+
215
+ # Reset the log to only retained events.
216
+ handle.seek(0)
217
+ handle.truncate(0)
218
+ if retained_lines:
219
+ handle.write("\n".join(retained_lines) + "\n")
220
+ handle.flush()
221
+ os.fsync(handle.fileno())
222
+
223
+
224
+ def _should_rotate(path: Path, current_ts: datetime, max_size_mb: int) -> bool:
225
+ # Decide whether a daily or size-based rotation is needed.
226
+ if not path.exists():
227
+ return False
228
+
229
+ current_day = current_ts.astimezone(timezone.utc).date()
230
+ last_write = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
231
+ last_day = last_write.date()
232
+ if last_day != current_day:
233
+ return True
234
+
235
+ if max_size_mb <= 0:
236
+ return False
237
+ max_bytes = max_size_mb * 1024 * 1024
238
+ return path.stat().st_size > max_bytes
239
+
240
+
241
+ def _rotation_day(path: Path, current_ts: datetime) -> datetime.date:
242
+ # Use the last write date for backups to align with daily rotations.
243
+ if not path.exists():
244
+ return current_ts.astimezone(timezone.utc).date()
245
+ last_write = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
246
+ return last_write.date()
247
+
248
+
249
+ def _backup_path(path: Path, rotation_day: datetime.date) -> Path:
250
+ # Build a date-stamped backup path that avoids clobbering older files.
251
+ date_suffix = rotation_day.strftime("%Y-%m-%d")
252
+ candidate = path.with_name(f"{path.stem}.{date_suffix}{path.suffix}")
253
+ if not candidate.exists():
254
+ return candidate
255
+ index = 1
256
+ while True:
257
+ indexed = path.with_name(f"{path.stem}.{date_suffix}.{index}{path.suffix}")
258
+ if not indexed.exists():
259
+ return indexed
260
+ index += 1
261
+
262
+
263
+ def _copy_and_collect_activity(handle, backup_path: Path) -> tuple[dict[str, datetime], dict[str, str]]:
264
+ # Copy the current log to a backup while recording worker activity.
265
+ last_seen: dict[str, datetime] = {}
266
+ last_state: dict[str, str] = {}
267
+ handle.seek(0)
268
+ with backup_path.open("w", encoding="utf-8") as backup:
269
+ for line in handle:
270
+ backup.write(line)
271
+ line = line.strip()
272
+ if not line:
273
+ continue
274
+ # Ignore malformed JSON while copying the raw line.
275
+ try:
276
+ payload = json.loads(line)
277
+ except json.JSONDecodeError:
278
+ continue
279
+ event = _parse_event(payload)
280
+ _track_event_activity(event, last_seen, last_state)
281
+ return last_seen, last_state
282
+
283
+
284
+ def _track_event_activity(
285
+ event: WorkerEvent,
286
+ last_seen: dict[str, datetime],
287
+ last_state: dict[str, str],
288
+ ) -> None:
289
+ # Update last-seen and last-state maps from a worker event.
290
+ try:
291
+ event_ts = _parse_timestamp(event.ts)
292
+ except ValueError:
293
+ return
294
+
295
+ if event.type == "snapshot":
296
+ _track_snapshot_activity(event.data, event_ts, last_seen, last_state)
297
+ return
298
+
299
+ if not event.worker_id:
300
+ return
301
+
302
+ last_seen[event.worker_id] = event_ts
303
+ state = _state_from_event_type(event.type)
304
+ if state:
305
+ last_state[event.worker_id] = state
306
+
307
+
308
+ def _track_snapshot_activity(
309
+ data: dict,
310
+ event_ts: datetime,
311
+ last_seen: dict[str, datetime],
312
+ last_state: dict[str, str],
313
+ ) -> None:
314
+ # Update state from snapshot payloads.
315
+ workers = data.get("workers")
316
+ if not isinstance(workers, list):
317
+ return
318
+ for worker in workers:
319
+ if not isinstance(worker, dict):
320
+ continue
321
+ worker_id = _snapshot_worker_id(worker)
322
+ if not worker_id:
323
+ continue
324
+ state = worker.get("state")
325
+ if isinstance(state, str) and state:
326
+ last_state[worker_id] = state
327
+ if state == "active":
328
+ last_seen[worker_id] = event_ts
329
+
330
+
331
+ def _state_from_event_type(event_type: EventType) -> str | None:
332
+ # Map event types to "active"/"idle"/"closed" state labels.
333
+ if event_type in ("worker_started", "worker_active"):
334
+ return "active"
335
+ if event_type == "worker_idle":
336
+ return "idle"
337
+ if event_type == "worker_closed":
338
+ return "closed"
339
+ return None
340
+
341
+
342
+ def _snapshot_worker_id(worker: dict) -> str | None:
343
+ # Identify a worker id inside snapshot payloads.
344
+ for key in ("session_id", "worker_id", "id"):
345
+ value = worker.get(key)
346
+ if value:
347
+ return str(value)
348
+ return None
349
+
350
+
351
+ def _select_workers_to_keep(
352
+ last_seen: dict[str, datetime],
353
+ last_state: dict[str, str],
354
+ current_ts: datetime,
355
+ recent_hours: int,
356
+ ) -> set[str]:
357
+ # Build the retention set from active and recently active workers.
358
+ keep_ids = {worker_id for worker_id, state in last_state.items() if state == "active"}
359
+ if recent_hours <= 0:
360
+ return keep_ids
361
+ threshold = current_ts.astimezone(timezone.utc) - timedelta(hours=recent_hours)
362
+ for worker_id, seen in last_seen.items():
363
+ if seen >= threshold:
364
+ keep_ids.add(worker_id)
365
+ return keep_ids
366
+
367
+
368
+ def _filter_retained_events(handle, keep_ids: set[str]) -> list[str]:
369
+ # Filter events to only those associated with retained workers.
370
+ retained: list[str] = []
371
+ handle.seek(0)
372
+ for line in handle:
373
+ line = line.strip()
374
+ if not line:
375
+ continue
376
+ # Skip malformed JSON entries without failing rotation.
377
+ try:
378
+ payload = json.loads(line)
379
+ except json.JSONDecodeError:
380
+ continue
381
+ event = _parse_event(payload)
382
+ if event.type == "snapshot":
383
+ # Retain only snapshot entries related to preserved workers.
384
+ filtered = _filter_snapshot_event(event, keep_ids)
385
+ if filtered is None:
386
+ continue
387
+ retained.append(json.dumps(_event_to_dict(filtered), ensure_ascii=False))
388
+ continue
389
+ if event.worker_id and event.worker_id in keep_ids:
390
+ retained.append(json.dumps(_event_to_dict(event), ensure_ascii=False))
391
+ return retained
392
+
393
+
394
+ def _filter_snapshot_event(event: WorkerEvent, keep_ids: set[str]) -> WorkerEvent | None:
395
+ # Drop snapshot entries that don't include retained workers.
396
+ data = dict(event.data or {})
397
+ workers = data.get("workers")
398
+ if not isinstance(workers, list):
399
+ return None
400
+ filtered_workers = []
401
+ for worker in workers:
402
+ if not isinstance(worker, dict):
403
+ continue
404
+ worker_id = _snapshot_worker_id(worker)
405
+ if worker_id and worker_id in keep_ids:
406
+ filtered_workers.append(worker)
407
+ if not filtered_workers:
408
+ return None
409
+ data["workers"] = filtered_workers
410
+ data["count"] = len(filtered_workers)
411
+ return WorkerEvent(ts=event.ts, type=event.type, worker_id=None, data=data)
412
+
413
+
414
+ def _latest_event_timestamp(events: list[WorkerEvent]) -> datetime:
415
+ # Use the newest timestamp in a batch to evaluate rotation boundaries.
416
+ latest = datetime.min.replace(tzinfo=timezone.utc)
417
+ for event in events:
418
+ try:
419
+ event_ts = _parse_timestamp(event.ts)
420
+ except ValueError:
421
+ continue
422
+ if event_ts > latest:
423
+ latest = event_ts
424
+ if latest == datetime.min.replace(tzinfo=timezone.utc):
425
+ return datetime.now(timezone.utc)
426
+ return latest
427
+
428
+
429
+ def _lock_file(handle) -> None:
430
+ # Acquire an exclusive lock for the file handle.
431
+ if fcntl is not None:
432
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
433
+ return
434
+ if msvcrt is not None: # pragma: no cover - platform-specific
435
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
436
+ return
437
+ raise RuntimeError("File locking is not supported on this platform.")
438
+
439
+
440
+ def _unlock_file(handle) -> None:
441
+ # Release any lock held on the file handle.
442
+ if fcntl is not None:
443
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
444
+ return
445
+ if msvcrt is not None: # pragma: no cover - platform-specific
446
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
447
+ return
448
+ raise RuntimeError("File locking is not supported on this platform.")
449
+
450
+
451
+ def _normalize_since(since: datetime | None) -> datetime | None:
452
+ # Normalize timestamps for consistent comparisons.
453
+ if since is None:
454
+ return None
455
+ if since.tzinfo is None:
456
+ return since.replace(tzinfo=timezone.utc)
457
+ return since.astimezone(timezone.utc)
458
+
459
+
460
+ def _parse_timestamp(value: str) -> datetime:
461
+ # Parse ISO 8601 timestamps, including Zulu suffixes.
462
+ if value.endswith("Z"):
463
+ value = value[:-1] + "+00:00"
464
+ parsed = datetime.fromisoformat(value)
465
+ if parsed.tzinfo is None:
466
+ return parsed.replace(tzinfo=timezone.utc)
467
+ return parsed
468
+
469
+
470
+ def _parse_event(payload: dict) -> WorkerEvent:
471
+ # Convert a JSON payload into a WorkerEvent instance.
472
+ return WorkerEvent(
473
+ ts=str(payload["ts"]),
474
+ type=payload["type"],
475
+ worker_id=payload.get("worker_id"),
476
+ data=payload.get("data") or {},
477
+ )
@@ -0,0 +1,173 @@
1
+ """Idle detection based on file activity and process state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import time
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ AgentType = Literal["claude", "codex"]
13
+
14
+
15
+ @dataclass
16
+ class Worker:
17
+ """Minimal worker state for idle detection."""
18
+
19
+ project_path: str
20
+ claude_session_id: str | None
21
+ agent_type: AgentType
22
+ is_idle: bool = False
23
+ message_count: int | None = None
24
+ last_message_count: int | None = None
25
+ last_message_timestamp: float | None = None
26
+ output_path: Path | None = None
27
+ pid: int | None = None
28
+
29
+
30
+ def get_project_slug(project_path: str) -> str:
31
+ """Convert a filesystem path to Claude's project directory slug."""
32
+ return project_path.replace("/", "-").replace(".", "-")
33
+
34
+
35
+ def get_claude_jsonl_path(worker: Worker) -> Path | None:
36
+ """Construct JSONL path for Claude Code worker."""
37
+ if not worker.project_path or not worker.claude_session_id:
38
+ return None
39
+ project_slug = get_project_slug(worker.project_path)
40
+ return Path.home() / ".claude" / "projects" / project_slug / f"{worker.claude_session_id}.jsonl"
41
+
42
+
43
+ def check_file_idle(path: Path, threshold_seconds: int) -> tuple[bool, int]:
44
+ """Check if file mtime exceeds threshold, return (is_idle, age_seconds)."""
45
+ try:
46
+ mtime = path.stat().st_mtime
47
+ except OSError:
48
+ return False, 0
49
+
50
+ age_seconds = max(0, int(time.time() - mtime))
51
+ return age_seconds >= threshold_seconds, age_seconds
52
+
53
+
54
+ # Compare message counts and update worker state when activity changes.
55
+ def _detect_idle_from_message_count(
56
+ worker: Worker,
57
+ idle_threshold_seconds: int,
58
+ ) -> tuple[bool, str | None] | None:
59
+ message_count = getattr(worker, "message_count", None)
60
+ if message_count is None:
61
+ return None
62
+
63
+ now = time.time()
64
+ last_count = getattr(worker, "last_message_count", None)
65
+ last_timestamp = getattr(worker, "last_message_timestamp", None)
66
+
67
+ if last_count is None or last_timestamp is None:
68
+ # Seed tracking state on first observation.
69
+ setattr(worker, "last_message_count", message_count)
70
+ setattr(worker, "last_message_timestamp", now)
71
+ return None
72
+
73
+ if message_count != last_count:
74
+ # Activity observed, reset tracking window.
75
+ setattr(worker, "last_message_count", message_count)
76
+ setattr(worker, "last_message_timestamp", now)
77
+ return False, None
78
+
79
+ idle_for = now - last_timestamp
80
+ if idle_for >= idle_threshold_seconds:
81
+ # No message activity within the threshold window.
82
+ return True, f"message_count_stalled:{int(idle_for)}s"
83
+
84
+ return False, None
85
+
86
+
87
+ # Best-effort process probe for Codex workers without output file updates.
88
+ def _detect_idle_from_process(worker: Worker) -> tuple[bool, str | None] | None:
89
+ pid = getattr(worker, "pid", None)
90
+ if not pid:
91
+ return None
92
+
93
+ try:
94
+ # Raises OSError when the PID does not exist.
95
+ os.kill(pid, 0)
96
+ except OSError:
97
+ return True, "process_exited"
98
+
99
+ try:
100
+ result = subprocess.run(
101
+ ["ps", "-o", "state=", "-p", str(pid)],
102
+ check=False,
103
+ capture_output=True,
104
+ text=True,
105
+ )
106
+ except OSError:
107
+ return None
108
+
109
+ state = result.stdout.strip()
110
+ if not state:
111
+ return None
112
+
113
+ # "S" (sleeping) is a best-effort proxy for waiting on stdin.
114
+ if state[0] in {"S", "I"}:
115
+ return True, "process_sleeping"
116
+
117
+ return False, None
118
+
119
+
120
+ def detect_worker_idle(
121
+ worker: Worker,
122
+ idle_threshold_seconds: int = 300,
123
+ ) -> tuple[bool, str | None]:
124
+ """
125
+ Detect if worker is idle based on file activity.
126
+
127
+ Returns (is_idle, reason) where reason explains how idle was detected.
128
+ """
129
+ # Get current idle state, handling both attributes and methods
130
+ current_idle_attr = getattr(worker, "is_idle", False)
131
+ if callable(current_idle_attr):
132
+ # ManagedSession has is_idle() method, call it
133
+ try:
134
+ current_idle = current_idle_attr()
135
+ except Exception:
136
+ current_idle = False
137
+ else:
138
+ current_idle = bool(current_idle_attr)
139
+
140
+ # Claude workers: JSONL mtime is primary, message count is secondary.
141
+ if worker.agent_type == "claude":
142
+ jsonl_path = get_claude_jsonl_path(worker)
143
+ if jsonl_path and jsonl_path.exists():
144
+ is_idle, age_seconds = check_file_idle(jsonl_path, idle_threshold_seconds)
145
+ if is_idle:
146
+ return True, f"jsonl_mtime:{age_seconds}s"
147
+ return False, None
148
+
149
+ # Fall back to message count when the JSONL path is missing.
150
+ message_result = _detect_idle_from_message_count(worker, idle_threshold_seconds)
151
+ if message_result is not None:
152
+ return message_result
153
+
154
+ # No signal available, keep existing idle state.
155
+ return current_idle, None
156
+
157
+ # Codex workers: output file mtime is primary, process state is fallback.
158
+ if worker.agent_type == "codex":
159
+ output_path = getattr(worker, "output_path", None)
160
+ if output_path and output_path.exists():
161
+ is_idle, age_seconds = check_file_idle(output_path, idle_threshold_seconds)
162
+ if is_idle:
163
+ return True, f"output_mtime:{age_seconds}s"
164
+ return False, None
165
+
166
+ process_result = _detect_idle_from_process(worker)
167
+ if process_result is not None:
168
+ return process_result
169
+
170
+ # Nothing to inspect, preserve current state.
171
+ return current_idle, None
172
+
173
+ return current_idle, "unknown_agent_type"