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.
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
@@ -372,6 +372,7 @@ class SessionInfo:
372
372
 
373
373
  jsonl_path: Path
374
374
  session_id: str
375
+ agent_type: str = "claude"
375
376
 
376
377
 
377
378
  async def wait_for_any_idle(
@@ -403,7 +404,11 @@ async def wait_for_any_idle(
403
404
 
404
405
  while time.time() - start < timeout:
405
406
  for session in sessions:
406
- if is_idle(session.jsonl_path, session.session_id):
407
+ if session.agent_type == "codex":
408
+ idle = is_codex_idle(session.jsonl_path)
409
+ else:
410
+ idle = is_idle(session.jsonl_path, session.session_id)
411
+ if idle:
407
412
  return {
408
413
  "idle_session_id": session.session_id,
409
414
  "idle": True,
@@ -453,7 +458,11 @@ async def wait_for_all_idle(
453
458
  working_sessions = []
454
459
 
455
460
  for session in sessions:
456
- if is_idle(session.jsonl_path, session.session_id):
461
+ if session.agent_type == "codex":
462
+ idle = is_codex_idle(session.jsonl_path)
463
+ else:
464
+ idle = is_idle(session.jsonl_path, session.session_id)
465
+ if idle:
457
466
  idle_sessions.append(session.session_id)
458
467
  else:
459
468
  working_sessions.append(session.session_id)
@@ -474,7 +483,11 @@ async def wait_for_all_idle(
474
483
  idle_sessions = []
475
484
  working_sessions = []
476
485
  for session in sessions:
477
- if is_idle(session.jsonl_path, session.session_id):
486
+ if session.agent_type == "codex":
487
+ idle = is_codex_idle(session.jsonl_path)
488
+ else:
489
+ idle = is_idle(session.jsonl_path, session.session_id)
490
+ if idle:
478
491
  idle_sessions.append(session.session_id)
479
492
  else:
480
493
  working_sessions.append(session.session_id)
@@ -705,75 +705,6 @@ async def start_agent_in_session(
705
705
  )
706
706
 
707
707
 
708
- async def start_claude_in_session(
709
- session: "ItermSession",
710
- project_path: str,
711
- dangerously_skip_permissions: bool = False,
712
- env: Optional[dict[str, str]] = None,
713
- shell_ready_timeout: float = 10.0,
714
- claude_ready_timeout: float = 30.0,
715
- stop_hook_marker_id: Optional[str] = None,
716
- ) -> None:
717
- """
718
- Start Claude Code in an existing iTerm2 session.
719
-
720
- Convenience wrapper around start_agent_in_session() for Claude.
721
- Changes to the project directory and launches Claude Code in a single
722
- atomic command (cd && claude). Waits for shell readiness before sending
723
- the command, then waits for Claude's startup banner to appear.
724
-
725
- The command used to launch Claude Code can be overridden by setting
726
- the CLAUDE_TEAM_COMMAND environment variable (defaults to "claude").
727
- This is useful for running alternative Claude CLI implementations
728
- like "happy" or for testing purposes.
729
-
730
- Args:
731
- session: iTerm2 session to use
732
- project_path: Directory to run Claude in
733
- dangerously_skip_permissions: If True, start with --dangerously-skip-permissions
734
- env: Optional dict of environment variables to set before running claude
735
- shell_ready_timeout: Max seconds to wait for shell prompt
736
- claude_ready_timeout: Max seconds to wait for Claude to start and show banner
737
- stop_hook_marker_id: If provided, inject a Stop hook that logs this marker
738
- to the JSONL for completion detection
739
-
740
- Raises:
741
- RuntimeError: If shell not ready or Claude fails to start within timeout
742
- """
743
- from .cli_backends import claude_cli
744
-
745
- await start_agent_in_session(
746
- session=session,
747
- cli=claude_cli,
748
- project_path=project_path,
749
- dangerously_skip_permissions=dangerously_skip_permissions,
750
- env=env,
751
- shell_ready_timeout=shell_ready_timeout,
752
- agent_ready_timeout=claude_ready_timeout,
753
- stop_hook_marker_id=stop_hook_marker_id,
754
- )
755
-
756
-
757
- # Legacy alias for backward compatibility with Claude-specific code
758
- # that checks for banner patterns. Uses wait_for_agent_ready with claude_cli.
759
- async def _wait_for_claude_ready_via_agent(
760
- session: "ItermSession",
761
- timeout_seconds: float = 15.0,
762
- poll_interval: float = 0.2,
763
- stable_count: int = 2,
764
- ) -> bool:
765
- """Internal helper - uses wait_for_agent_ready with Claude CLI."""
766
- from .cli_backends import claude_cli
767
-
768
- return await wait_for_agent_ready(
769
- session=session,
770
- cli=claude_cli,
771
- timeout_seconds=timeout_seconds,
772
- poll_interval=poll_interval,
773
- stable_count=stable_count,
774
- )
775
-
776
-
777
708
  # =============================================================================
778
709
  # Multi-Pane Layouts
779
710
  # =============================================================================
@@ -983,15 +914,17 @@ async def create_multi_claude_layout(
983
914
  profile_customizations=profile_customizations,
984
915
  )
985
916
 
917
+ from .cli_backends import claude_cli
918
+
986
919
  # Start Claude in all panes in parallel.
987
- # Each start_claude_in_session call uses wait_for_shell_ready() internally
988
- # which provides proper readiness detection, so no sleeps between starts needed.
920
+ # start_agent_in_session uses wait_for_shell_ready() internally, so no sleeps needed.
989
921
  async def start_claude_for_pane(pane_name: str, project_path: str) -> None:
990
922
  session = panes[pane_name]
991
923
  pane_env = project_envs.get(pane_name) if project_envs else None
992
924
  marker_id = pane_marker_ids.get(pane_name) if pane_marker_ids else None
993
- await start_claude_in_session(
925
+ await start_agent_in_session(
994
926
  session=session,
927
+ cli=claude_cli,
995
928
  project_path=project_path,
996
929
  dangerously_skip_permissions=skip_permissions,
997
930
  env=pane_env,
@@ -1116,4 +1049,3 @@ async def get_window_for_session(
1116
1049
  return window
1117
1050
  return None
1118
1051
 
1119
-
@@ -2,7 +2,7 @@
2
2
  Session Registry for Claude Team MCP
3
3
 
4
4
  Tracks all spawned Claude Code sessions, maintaining the mapping between
5
- our session IDs, iTerm2 session objects, and Claude JSONL session IDs.
5
+ our session IDs, terminal session handles, and Claude JSONL session IDs.
6
6
  """
7
7
 
8
8
  import uuid
@@ -10,12 +10,14 @@ from dataclasses import dataclass, field
10
10
  from datetime import datetime
11
11
  from enum import Enum
12
12
  from pathlib import Path
13
- from typing import TYPE_CHECKING, Literal, Optional
13
+ from typing import Literal, Optional
14
14
 
15
- if TYPE_CHECKING:
16
- from iterm2.session import Session as ItermSession
17
-
18
- from .session_state import get_project_dir, parse_session
15
+ from .session_state import (
16
+ find_codex_session_by_internal_id,
17
+ get_project_dir,
18
+ parse_session,
19
+ )
20
+ from .terminal_backends import TerminalSession
19
21
 
20
22
  # Type alias for supported agent types
21
23
  AgentType = Literal["claude", "codex"]
@@ -31,16 +33,16 @@ class TerminalId:
31
33
  tools to accept terminal IDs directly for recovery scenarios.
32
34
 
33
35
  Attributes:
34
- terminal_type: Terminal emulator type ("iterm", "zed", "vscode", etc.)
36
+ backend_id: Terminal backend identifier ("iterm", "tmux", "zed", etc.)
35
37
  native_id: Terminal's native session ID (e.g., iTerm's UUID)
36
38
  """
37
39
 
38
- terminal_type: str
40
+ backend_id: str
39
41
  native_id: str
40
42
 
41
43
  def __str__(self) -> str:
42
44
  """For display: 'iterm:DB29DB03-...'"""
43
- return f"{self.terminal_type}:{self.native_id}"
45
+ return f"{self.backend_id}:{self.native_id}"
44
46
 
45
47
  @classmethod
46
48
  def from_string(cls, s: str) -> "TerminalId":
@@ -50,9 +52,8 @@ class TerminalId:
50
52
  Falls back to treating bare IDs as iTerm for backwards compatibility.
51
53
  """
52
54
  if ":" in s:
53
- terminal_type, native_id = s.split(":", 1)
54
- return cls(terminal_type, native_id)
55
- # Assume bare ID is iTerm for backwards compatibility
55
+ backend_id, native_id = s.split(":", 1)
56
+ return cls(backend_id, native_id)
56
57
  return cls("iterm", s)
57
58
 
58
59
 
@@ -69,12 +70,12 @@ class ManagedSession:
69
70
  """
70
71
  Represents a spawned Claude Code session.
71
72
 
72
- Tracks the iTerm2 session object, project path, and Claude session ID
73
+ Tracks terminal session metadata, project path, and Claude session ID
73
74
  discovered from the JSONL file.
74
75
  """
75
76
 
76
77
  session_id: str # Our assigned ID (e.g., "worker-1")
77
- iterm_session: "ItermSession"
78
+ terminal_session: TerminalSession
78
79
  project_path: str
79
80
  claude_session_id: Optional[str] = None # Discovered from JSONL
80
81
  name: Optional[str] = None # Optional friendly name
@@ -87,16 +88,19 @@ class ManagedSession:
87
88
  worktree_path: Optional[Path] = None # Path to worker's git worktree if any
88
89
  main_repo_path: Optional[Path] = None # Path to main git repo (for worktree cleanup)
89
90
 
90
- # Terminal-agnostic identifier (auto-populated from iterm_session if not set)
91
+ # Terminal-agnostic identifier (auto-populated from terminal_session if not set)
91
92
  terminal_id: Optional[TerminalId] = None
92
93
 
93
94
  # Agent type: "claude" (default) or "codex"
94
95
  agent_type: AgentType = "claude"
95
96
 
96
97
  def __post_init__(self):
97
- """Auto-populate terminal_id from iterm_session if not set."""
98
- if self.terminal_id is None and self.iterm_session is not None:
99
- self.terminal_id = TerminalId("iterm", self.iterm_session.session_id)
98
+ """Auto-populate terminal_id from terminal_session if not set."""
99
+ if self.terminal_id is None:
100
+ self.terminal_id = TerminalId(
101
+ self.terminal_session.backend_id,
102
+ self.terminal_session.native_id,
103
+ )
100
104
 
101
105
  def to_dict(self) -> dict:
102
106
  """Convert to dictionary for MCP tool responses."""
@@ -148,15 +152,21 @@ class ManagedSession:
148
152
  Get the path to this session's JSONL file.
149
153
 
150
154
  For Claude workers: uses marker-based discovery in ~/.claude/projects/.
151
- For Codex workers: searches ~/.codex/sessions/ for matching session files.
155
+ For Codex workers: uses marker-based discovery in ~/.codex/sessions/.
152
156
 
153
157
  Returns:
154
158
  Path object, or None if session cannot be discovered
155
159
  """
156
160
  if self.agent_type == "codex":
157
- # For Codex, search the sessions directory
158
161
  from .idle_detection import find_codex_session_file
159
162
 
163
+ # Prefer marker-based match, fall back to most recent for legacy sessions.
164
+ match = find_codex_session_by_internal_id(
165
+ self.session_id,
166
+ max_age_seconds=600,
167
+ )
168
+ if match:
169
+ return match.jsonl_path
160
170
  return find_codex_session_file(max_age_seconds=600)
161
171
  else:
162
172
  # For Claude, use marker-based discovery
@@ -205,8 +215,14 @@ class ManagedSession:
205
215
  if self.agent_type == "codex":
206
216
  from .idle_detection import find_codex_session_file, is_codex_idle
207
217
 
208
- # Find the session file (will be discovered from ~/.codex/sessions/)
209
- session_file = find_codex_session_file(max_age_seconds=600)
218
+ # Prefer marker-based match, fall back to most recent for legacy sessions.
219
+ match = find_codex_session_by_internal_id(
220
+ self.session_id,
221
+ max_age_seconds=600,
222
+ )
223
+ session_file = match.jsonl_path if match else None
224
+ if not session_file:
225
+ session_file = find_codex_session_file(max_age_seconds=600)
210
226
  if not session_file:
211
227
  return False
212
228
  return is_codex_idle(session_file)
@@ -269,7 +285,7 @@ class SessionRegistry:
269
285
 
270
286
  def add(
271
287
  self,
272
- iterm_session: "ItermSession",
288
+ terminal_session: TerminalSession,
273
289
  project_path: str,
274
290
  name: Optional[str] = None,
275
291
  session_id: Optional[str] = None,
@@ -278,7 +294,7 @@ class SessionRegistry:
278
294
  Add a new session to the registry.
279
295
 
280
296
  Args:
281
- iterm_session: iTerm2 session object
297
+ terminal_session: Backend-agnostic terminal session handle
282
298
  project_path: Directory where Claude is running
283
299
  name: Optional friendly name
284
300
  session_id: Optional specific ID (auto-generated if not provided)
@@ -291,7 +307,7 @@ class SessionRegistry:
291
307
 
292
308
  session = ManagedSession(
293
309
  session_id=session_id,
294
- iterm_session=iterm_session,
310
+ terminal_session=terminal_session,
295
311
  project_path=project_path,
296
312
  name=name,
297
313
  )
@@ -331,7 +347,8 @@ class SessionRegistry:
331
347
 
332
348
  Lookup order (most specific first):
333
349
  1. Internal session_id (e.g., "d875b833")
334
- 2. Terminal native ID (e.g., "DB29DB03-AA52-4FBF-879A-4DA2C5F9F823")
350
+ 2. Terminal ID with backend prefix (e.g., "iterm:DB29DB03-..."),
351
+ or a bare iTerm ID for backwards compatibility
335
352
  3. Session name
336
353
 
337
354
  After MCP restart, internal IDs are lost until import. This method