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/__init__.py +11 -0
- claude_team/events.py +477 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +95 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +31 -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 +646 -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 +221 -142
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +59 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.7.0.dist-info/RECORD +52 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.7.0.dist-info}/entry_points.txt +0 -0
claude_team_mcp/session_state.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
...
|