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.
@@ -15,6 +15,8 @@ from typing import Any, Optional
15
15
 
16
16
  # Claude projects directory
17
17
  CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
18
+ # Codex sessions directory
19
+ CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
18
20
 
19
21
 
20
22
  def parse_timestamp(entry: dict) -> datetime:
@@ -207,10 +209,22 @@ MARKER_SUFFIX = "!>"
207
209
  ITERM_MARKER_PREFIX = "<!claude-team-iterm:"
208
210
  ITERM_MARKER_SUFFIX = "!>"
209
211
 
212
+ # Tmux-specific marker for session discovery/recovery
213
+ # Tmux pane ids can change across restarts, so we log the pane id in JSONL
214
+ # to recover sessions that were started by claude-team.
215
+ TMUX_MARKER_PREFIX = "<!claude-team-tmux:"
216
+ TMUX_MARKER_SUFFIX = "!>"
217
+
218
+ # Project path marker for Codex session recovery
219
+ PROJECT_MARKER_PREFIX = "<!claude-team-project:"
220
+ PROJECT_MARKER_SUFFIX = "!>"
221
+
210
222
 
211
223
  def generate_marker_message(
212
224
  session_id: str,
213
225
  iterm_session_id: Optional[str] = None,
226
+ tmux_pane_ids: Optional[list[str]] = None,
227
+ project_path: Optional[str] = None,
214
228
  ) -> str:
215
229
  """
216
230
  Generate a marker message to send to a session for JSONL correlation.
@@ -222,15 +236,33 @@ def generate_marker_message(
222
236
  session_id: The managed session ID (e.g., "worker-1")
223
237
  iterm_session_id: Optional iTerm2 session ID for discovery/recovery.
224
238
  When provided, an additional iTerm-specific marker is emitted.
239
+ tmux_pane_ids: Optional tmux pane IDs for discovery/recovery.
240
+ When provided, tmux-specific markers are emitted for each pane.
241
+ project_path: Optional project path for Codex session recovery.
242
+ When provided, a project marker is emitted.
225
243
 
226
244
  Returns:
227
245
  A message string to send to the session
228
246
  """
229
- marker = f"{MARKER_PREFIX}{session_id}{MARKER_SUFFIX}"
247
+ marker_lines = [f"{MARKER_PREFIX}{session_id}{MARKER_SUFFIX}"]
230
248
 
231
249
  # Add iTerm-specific marker if provided (for session recovery after MCP restart)
232
250
  if iterm_session_id:
233
- marker += f"\n{ITERM_MARKER_PREFIX}{iterm_session_id}{ITERM_MARKER_SUFFIX}"
251
+ marker_lines.append(
252
+ f"{ITERM_MARKER_PREFIX}{iterm_session_id}{ITERM_MARKER_SUFFIX}"
253
+ )
254
+
255
+ if tmux_pane_ids:
256
+ for pane_id in tmux_pane_ids:
257
+ marker_lines.append(f"{TMUX_MARKER_PREFIX}{pane_id}{TMUX_MARKER_SUFFIX}")
258
+
259
+ # Add project path marker if provided (used for Codex recovery)
260
+ if project_path:
261
+ marker_lines.append(
262
+ f"{PROJECT_MARKER_PREFIX}{project_path}{PROJECT_MARKER_SUFFIX}"
263
+ )
264
+
265
+ marker = "\n".join(marker_lines)
234
266
 
235
267
  return (
236
268
  f"{marker}\n\n"
@@ -280,6 +312,46 @@ def extract_iterm_session_id(text: str) -> Optional[str]:
280
312
  return text[start:end]
281
313
 
282
314
 
315
+ def extract_project_path(text: str) -> Optional[str]:
316
+ """
317
+ Extract a project path from marker text if present.
318
+
319
+ Args:
320
+ text: Text that may contain a project path marker
321
+
322
+ Returns:
323
+ The project path from the marker, or None if no marker found
324
+ """
325
+ start = text.find(PROJECT_MARKER_PREFIX)
326
+ if start == -1:
327
+ return None
328
+ start += len(PROJECT_MARKER_PREFIX)
329
+ end = text.find(PROJECT_MARKER_SUFFIX, start)
330
+ if end == -1:
331
+ return None
332
+ return text[start:end]
333
+
334
+
335
+ def extract_tmux_pane_id(text: str) -> Optional[str]:
336
+ """
337
+ Extract a tmux pane ID from marker text if present.
338
+
339
+ Args:
340
+ text: Text that may contain a tmux marker
341
+
342
+ Returns:
343
+ The tmux pane ID from the marker, or None if no marker found
344
+ """
345
+ start = text.find(TMUX_MARKER_PREFIX)
346
+ if start == -1:
347
+ return None
348
+ start += len(TMUX_MARKER_PREFIX)
349
+ end = text.find(TMUX_MARKER_SUFFIX, start)
350
+ if end == -1:
351
+ return None
352
+ return text[start:end]
353
+
354
+
283
355
  def find_jsonl_by_marker(
284
356
  project_path: str,
285
357
  session_id: str,
@@ -344,6 +416,199 @@ class ItermSessionMatch:
344
416
  project_path: str # Recovered from directory slug
345
417
 
346
418
 
419
+ @dataclass
420
+ class TmuxSessionMatch:
421
+ """Result of matching a tmux pane ID to a JSONL file."""
422
+
423
+ tmux_pane_id: str
424
+ internal_session_id: str # Our claude-team session ID
425
+ jsonl_path: Path
426
+ project_path: str # Recovered from directory slug
427
+
428
+
429
+ @dataclass
430
+ class CodexSessionMatch:
431
+ """Result of matching a Codex session marker to a JSONL file."""
432
+
433
+ iterm_session_id: Optional[str]
434
+ internal_session_id: str
435
+ jsonl_path: Path
436
+ project_path: str
437
+
438
+
439
+ # Helper to iterate recent Codex session files for marker scans.
440
+ def _iter_recent_codex_session_files(max_age_seconds: int) -> list[Path]:
441
+ now = time.time()
442
+ cutoff = now - max_age_seconds
443
+ recent_dirs: list[Path] = []
444
+
445
+ # Walk newest date directories first (limit to a few days to avoid scanning too much).
446
+ if not CODEX_SESSIONS_DIR.exists():
447
+ return []
448
+
449
+ # Build a short list of recent day directories (YYYY/MM/DD) to scan.
450
+ for year_dir in sorted(CODEX_SESSIONS_DIR.iterdir(), reverse=True):
451
+ if not year_dir.is_dir():
452
+ continue
453
+ for month_dir in sorted(year_dir.iterdir(), reverse=True):
454
+ if not month_dir.is_dir():
455
+ continue
456
+ for day_dir in sorted(month_dir.iterdir(), reverse=True):
457
+ if not day_dir.is_dir():
458
+ continue
459
+ recent_dirs.append(day_dir)
460
+ if len(recent_dirs) >= 3:
461
+ break
462
+ if len(recent_dirs) >= 3:
463
+ break
464
+ if len(recent_dirs) >= 3:
465
+ break
466
+
467
+ candidates: list[Path] = []
468
+
469
+ # Collect JSONL files in the recent directories, filtering by age.
470
+ for day_dir in recent_dirs:
471
+ for jsonl_file in day_dir.glob("rollout-*.jsonl"):
472
+ try:
473
+ mtime = jsonl_file.stat().st_mtime
474
+ except OSError:
475
+ continue
476
+ if mtime < cutoff:
477
+ continue
478
+ candidates.append(jsonl_file)
479
+
480
+ return candidates
481
+
482
+
483
+ # Helper to scan a Codex JSONL file for our markers.
484
+ def _scan_codex_markers(
485
+ jsonl_path: Path,
486
+ *,
487
+ iterm_session_id: Optional[str] = None,
488
+ internal_session_id: Optional[str] = None,
489
+ tmux_pane_id: Optional[str] = None,
490
+ ) -> Optional[CodexSessionMatch]:
491
+ try:
492
+ with open(jsonl_path, "r") as fp:
493
+ # Scan line-by-line so we can short-circuit as soon as markers are found.
494
+ for line in fp:
495
+ if (
496
+ MARKER_PREFIX not in line
497
+ and ITERM_MARKER_PREFIX not in line
498
+ and TMUX_MARKER_PREFIX not in line
499
+ and PROJECT_MARKER_PREFIX not in line
500
+ ):
501
+ continue
502
+
503
+ # Extract markers directly from the JSON line string (no full JSON parse).
504
+ found_internal = extract_marker_session_id(line)
505
+ found_iterm = extract_iterm_session_id(line)
506
+ found_tmux = extract_tmux_pane_id(line)
507
+ found_project = extract_project_path(line)
508
+
509
+ # Enforce target filters if provided.
510
+ if internal_session_id and found_internal != internal_session_id:
511
+ continue
512
+ if iterm_session_id and found_iterm != iterm_session_id:
513
+ continue
514
+ if tmux_pane_id and found_tmux != tmux_pane_id:
515
+ continue
516
+
517
+ # Require both internal ID and project path for a valid match.
518
+ if found_internal and found_project:
519
+ return CodexSessionMatch(
520
+ iterm_session_id=found_iterm,
521
+ internal_session_id=found_internal,
522
+ jsonl_path=jsonl_path,
523
+ project_path=found_project,
524
+ )
525
+ except OSError:
526
+ return None
527
+
528
+ return None
529
+
530
+
531
+ def find_codex_session_by_iterm_id(
532
+ iterm_session_id: str,
533
+ max_age_seconds: int = 3600,
534
+ ) -> Optional[CodexSessionMatch]:
535
+ """
536
+ Find a Codex session file containing a specific iTerm session marker.
537
+
538
+ Scans recent Codex session files for our markers and returns the
539
+ first match that includes the iTerm session ID.
540
+
541
+ Args:
542
+ iterm_session_id: The iTerm2 session ID to search for
543
+ max_age_seconds: Only check files modified within this many seconds
544
+
545
+ Returns:
546
+ CodexSessionMatch with recovery info, or None if not found
547
+ """
548
+ for jsonl_path in _iter_recent_codex_session_files(max_age_seconds):
549
+ match = _scan_codex_markers(
550
+ jsonl_path,
551
+ iterm_session_id=iterm_session_id,
552
+ )
553
+ if match:
554
+ return match
555
+ return None
556
+
557
+
558
+ def find_codex_session_by_tmux_id(
559
+ tmux_pane_id: str,
560
+ max_age_seconds: int = 3600,
561
+ ) -> Optional[CodexSessionMatch]:
562
+ """
563
+ Find a Codex session file containing a specific tmux pane marker.
564
+
565
+ Scans recent Codex session files for our markers and returns the
566
+ first match that includes the tmux pane ID.
567
+
568
+ Args:
569
+ tmux_pane_id: The tmux pane ID to search for
570
+ max_age_seconds: Only check files modified within this many seconds
571
+
572
+ Returns:
573
+ CodexSessionMatch with recovery info, or None if not found
574
+ """
575
+ for jsonl_path in _iter_recent_codex_session_files(max_age_seconds):
576
+ match = _scan_codex_markers(
577
+ jsonl_path,
578
+ tmux_pane_id=tmux_pane_id,
579
+ )
580
+ if match:
581
+ return match
582
+ return None
583
+
584
+
585
+ def find_codex_session_by_internal_id(
586
+ session_id: str,
587
+ max_age_seconds: int = 3600,
588
+ ) -> Optional[CodexSessionMatch]:
589
+ """
590
+ Find a Codex session file containing a specific internal session marker.
591
+
592
+ Scans recent Codex session files for our internal session marker and
593
+ returns the first matching file.
594
+
595
+ Args:
596
+ session_id: The internal session ID to search for
597
+ max_age_seconds: Only check files modified within this many seconds
598
+
599
+ Returns:
600
+ CodexSessionMatch with recovery info, or None if not found
601
+ """
602
+ for jsonl_path in _iter_recent_codex_session_files(max_age_seconds):
603
+ match = _scan_codex_markers(
604
+ jsonl_path,
605
+ internal_session_id=session_id,
606
+ )
607
+ if match:
608
+ return match
609
+ return None
610
+
611
+
347
612
  def find_jsonl_by_iterm_id(
348
613
  iterm_session_id: str,
349
614
  max_age_seconds: int = 3600,
@@ -441,6 +706,103 @@ def find_jsonl_by_iterm_id(
441
706
  return None
442
707
 
443
708
 
709
+ def find_jsonl_by_tmux_id(
710
+ tmux_pane_id: str,
711
+ max_age_seconds: int = 3600,
712
+ ) -> Optional[TmuxSessionMatch]:
713
+ """
714
+ Find a JSONL file containing a specific tmux pane marker.
715
+
716
+ Scans all project directories in ~/.claude/projects/ for JSONLs
717
+ that contain the tmux-specific marker. This enables session recovery
718
+ after MCP server restart.
719
+
720
+ Only looks at root user messages (type="user", parentUuid=null) and
721
+ extracts markers from the message.content field for reliability.
722
+
723
+ Args:
724
+ tmux_pane_id: The tmux pane ID to search for
725
+ max_age_seconds: Only check files modified within this many seconds
726
+
727
+ Returns:
728
+ TmuxSessionMatch with full recovery info, or None if not found
729
+ """
730
+ tmux_marker = f"{TMUX_MARKER_PREFIX}{tmux_pane_id}{TMUX_MARKER_SUFFIX}"
731
+ now = time.time()
732
+
733
+ # Scan all project directories
734
+ if not CLAUDE_PROJECTS_DIR.exists():
735
+ return None
736
+
737
+ for project_dir in CLAUDE_PROJECTS_DIR.iterdir():
738
+ if not project_dir.is_dir():
739
+ continue
740
+
741
+ # Check JSONL files in this project
742
+ for f in project_dir.glob("*.jsonl"):
743
+ # Skip agent files
744
+ if f.name.startswith("agent-"):
745
+ continue
746
+
747
+ # Skip old files
748
+ try:
749
+ if now - f.stat().st_mtime > max_age_seconds:
750
+ continue
751
+ except OSError:
752
+ continue
753
+
754
+ # Parse JSONL looking for root user message with our markers
755
+ try:
756
+ with open(f, "r") as fp:
757
+ for line in fp:
758
+ line = line.strip()
759
+ if not line:
760
+ continue
761
+
762
+ try:
763
+ entry = json.loads(line)
764
+ except json.JSONDecodeError:
765
+ continue
766
+
767
+ # Only look at root user messages (our marker message)
768
+ if entry.get("type") != "user":
769
+ continue
770
+ if entry.get("parentUuid") is not None:
771
+ continue
772
+
773
+ # Extract message content
774
+ message = entry.get("message", {})
775
+ content = message.get("content", "")
776
+ if not isinstance(content, str):
777
+ continue
778
+
779
+ # Check for tmux marker in message content
780
+ if tmux_marker not in content:
781
+ continue
782
+
783
+ # Extract internal session ID from the same content
784
+ internal_id = extract_marker_session_id(content)
785
+ if not internal_id:
786
+ continue
787
+
788
+ # Recover project path from directory slug
789
+ project_path = unslugify_path(project_dir.name)
790
+ if not project_path:
791
+ continue
792
+
793
+ return TmuxSessionMatch(
794
+ tmux_pane_id=tmux_pane_id,
795
+ internal_session_id=internal_id,
796
+ jsonl_path=f,
797
+ project_path=project_path,
798
+ )
799
+
800
+ except Exception:
801
+ continue
802
+
803
+ return None
804
+
805
+
444
806
  async def await_marker_in_jsonl(
445
807
  project_path: str,
446
808
  session_id: str,
@@ -0,0 +1,31 @@
1
+ """Terminal backend implementations and interfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Mapping
7
+
8
+ from .base import TerminalBackend, TerminalSession
9
+ from .iterm import ItermBackend, MAX_PANES_PER_TAB
10
+ from .tmux import TmuxBackend
11
+
12
+
13
+ def select_backend_id(env: Mapping[str, str] | None = None) -> str:
14
+ """Select a terminal backend id based on environment configuration."""
15
+ environ = env or os.environ
16
+ configured = environ.get("CLAUDE_TEAM_TERMINAL_BACKEND")
17
+ if configured:
18
+ return configured.strip().lower()
19
+ if environ.get("TMUX"):
20
+ return "tmux"
21
+ return "iterm"
22
+
23
+
24
+ __all__ = [
25
+ "TerminalBackend",
26
+ "TerminalSession",
27
+ "ItermBackend",
28
+ "TmuxBackend",
29
+ "MAX_PANES_PER_TAB",
30
+ "select_backend_id",
31
+ ]
@@ -0,0 +1,106 @@
1
+ """
2
+ Terminal backend abstractions for Claude Team MCP.
3
+
4
+ Defines the backend protocol and a backend-agnostic session handle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Protocol, runtime_checkable
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class TerminalSession:
15
+ """
16
+ Backend-agnostic handle to a terminal session or pane.
17
+
18
+ Attributes:
19
+ backend_id: Identifier for the backend ("iterm", "tmux", etc.)
20
+ native_id: Backend-native session or pane id
21
+ handle: Backend-specific handle object (if any)
22
+ metadata: Optional backend-specific metadata
23
+ """
24
+
25
+ backend_id: str
26
+ native_id: str
27
+ handle: Any
28
+ metadata: dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ @runtime_checkable
32
+ class TerminalBackend(Protocol):
33
+ """
34
+ Protocol for terminal backend implementations.
35
+
36
+ Backends should provide thin adapters over their native APIs so
37
+ claude-team can manage sessions without hard-coding terminals.
38
+ """
39
+
40
+ @property
41
+ def backend_id(self) -> str:
42
+ """Return a stable backend identifier ("iterm", "tmux", etc.)."""
43
+ ...
44
+
45
+ def wrap_session(self, handle: Any) -> TerminalSession:
46
+ """Wrap a backend-native handle in a TerminalSession."""
47
+ ...
48
+
49
+ def unwrap_session(self, session: TerminalSession) -> Any:
50
+ """Extract the backend-native handle from a TerminalSession."""
51
+ ...
52
+
53
+ async def create_session(
54
+ self,
55
+ name: str | None = None,
56
+ *,
57
+ project_path: str | None = None,
58
+ issue_id: str | None = None,
59
+ coordinator_annotation: str | None = None,
60
+ profile: str | None = None,
61
+ profile_customizations: Any | None = None,
62
+ ) -> TerminalSession:
63
+ """Create a new terminal session/pane and return it."""
64
+ ...
65
+
66
+ async def send_text(self, session: TerminalSession, text: str) -> None:
67
+ """Send raw text to the terminal session."""
68
+ ...
69
+
70
+ async def send_key(self, session: TerminalSession, key: str) -> None:
71
+ """Send a special key (enter, ctrl-c, etc.) to the session."""
72
+ ...
73
+
74
+ async def read_screen_text(self, session: TerminalSession) -> str:
75
+ """Read visible screen content as text."""
76
+ ...
77
+
78
+ async def split_pane(
79
+ self,
80
+ session: TerminalSession,
81
+ *,
82
+ vertical: bool = True,
83
+ before: bool = False,
84
+ profile: str | None = None,
85
+ profile_customizations: Any | None = None,
86
+ ) -> TerminalSession:
87
+ """Split a session pane and return the new pane."""
88
+ ...
89
+
90
+ async def close_session(self, session: TerminalSession, force: bool = False) -> None:
91
+ """Close the session/pane, optionally forcing termination."""
92
+ ...
93
+
94
+ async def create_multi_pane_layout(
95
+ self,
96
+ layout: str,
97
+ *,
98
+ profile: str | None = None,
99
+ profile_customizations: dict[str, Any] | None = None,
100
+ ) -> dict[str, TerminalSession]:
101
+ """Create a new multi-pane layout and return pane mapping."""
102
+ ...
103
+
104
+ async def list_sessions(self) -> list[TerminalSession]:
105
+ """List all sessions known to the backend."""
106
+ ...