claude-team-mcp 0.3.2__py3-none-any.whl → 0.5.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.
@@ -6,7 +6,6 @@ from the original primitives.py for use in the MCP server.
6
6
  """
7
7
 
8
8
  import logging
9
- import os
10
9
  import re
11
10
  from typing import TYPE_CHECKING, Optional
12
11
 
@@ -18,6 +17,8 @@ if TYPE_CHECKING:
18
17
  from iterm2.tab import Tab as ItermTab
19
18
  from iterm2.window import Window as ItermWindow
20
19
 
20
+ from .cli_backends import AgentCLI
21
+
21
22
  from .subprocess_cache import cached_system_profiler
22
23
 
23
24
  logger = logging.getLogger("claude-team-mcp.iterm_utils")
@@ -95,6 +96,10 @@ async def send_prompt(session: "ItermSession", text: str, submit: bool = True) -
95
96
  The delay scales with text length since longer pastes take more time
96
97
  for the terminal to process.
97
98
 
99
+ Note: This function is primarily for Claude Code. For Codex, use
100
+ send_prompt_for_agent() which provides the longer pre-Enter delay
101
+ that Codex requires.
102
+
98
103
  Args:
99
104
  session: iTerm2 session object
100
105
  text: The text to send
@@ -126,6 +131,63 @@ async def send_prompt(session: "ItermSession", text: str, submit: bool = True) -
126
131
  await session.async_send_text(KEYS["enter"])
127
132
 
128
133
 
134
+ # Pre-Enter delay for Codex (in seconds).
135
+ # Codex uses crossterm in raw mode - batch text sending works fine,
136
+ # but it needs a longer delay before Enter than Claude does.
137
+ # Testing showed 200ms is reliable; 50ms (Claude's default) is too short.
138
+ CODEX_PRE_ENTER_DELAY = 0.5 # 500ms minimum for Codex input processing
139
+
140
+
141
+ async def send_prompt_for_agent(
142
+ session: "ItermSession",
143
+ text: str,
144
+ agent_type: str = "claude",
145
+ submit: bool = True,
146
+ ) -> None:
147
+ """
148
+ Send a prompt to an iTerm2 session, with agent-specific input handling.
149
+
150
+ Both Claude and Codex handle batch/burst text input correctly. The key
151
+ difference is the delay needed before pressing Enter:
152
+
153
+ - **Claude Code**: 50ms delay before Enter is sufficient.
154
+ - **Codex**: Needs ~250ms delay before Enter for reliable input processing.
155
+
156
+ Args:
157
+ session: iTerm2 session object
158
+ text: The text to send
159
+ agent_type: The agent type identifier ("claude" or "codex")
160
+ submit: If True, press Enter after sending text
161
+ """
162
+ import asyncio
163
+
164
+ if agent_type == "codex":
165
+ # Codex: batch send text, but use longer pre-Enter delay.
166
+ # For long/multi-line prompts, iTerm2's bracketed paste mode needs
167
+ # time to complete before Enter is sent. Use the same delay
168
+ # calculation as send_prompt() but ensure at least CODEX_PRE_ENTER_DELAY.
169
+ await session.async_send_text(text)
170
+ if submit:
171
+ # Calculate delay based on text length (same as send_prompt)
172
+ line_count = text.count("\n")
173
+ char_count = len(text)
174
+ if line_count > 0:
175
+ paste_delay = min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
176
+ else:
177
+ paste_delay = 0.05
178
+ # Use whichever is larger: paste delay or Codex minimum
179
+ delay = max(CODEX_PRE_ENTER_DELAY, paste_delay)
180
+ logger.debug(
181
+ "send_prompt_for_agent: codex chars=%d lines=%d delay=%.3fs",
182
+ char_count, line_count, delay
183
+ )
184
+ await asyncio.sleep(delay)
185
+ await session.async_send_text(KEYS["enter"])
186
+ else:
187
+ # Claude Code and other agents: use standard send_prompt
188
+ await send_prompt(session, text, submit=submit)
189
+
190
+
129
191
  async def read_screen(session: "ItermSession") -> list[str]:
130
192
  """
131
193
  Read all lines from an iTerm2 session's screen.
@@ -232,19 +294,15 @@ async def create_window(
232
294
  from iterm2.window import Window
233
295
 
234
296
  # Create the window
235
- # Note: We conditionally pass profile_customizations only when not None
236
- # due to iterm2 library's type stubs not marking it as Optional
297
+ # Build kwargs conditionally - only include profile if explicitly set,
298
+ # otherwise let iTerm2 use its default. Empty string causes INVALID_PROFILE_NAME.
299
+ kwargs: dict = {}
300
+ if profile is not None:
301
+ kwargs["profile"] = profile
237
302
  if profile_customizations is not None:
238
- window = await Window.async_create(
239
- connection,
240
- profile=profile if profile is not None else "",
241
- profile_customizations=profile_customizations,
242
- )
243
- else:
244
- window = await Window.async_create(
245
- connection,
246
- profile=profile if profile is not None else "",
247
- )
303
+ kwargs["profile_customizations"] = profile_customizations
304
+
305
+ window = await Window.async_create(connection, **kwargs)
248
306
 
249
307
  if window is None:
250
308
  raise RuntimeError("Failed to create iTerm2 window")
@@ -294,12 +352,14 @@ async def split_pane(
294
352
  Returns:
295
353
  The new session created in the split pane.
296
354
  """
297
- return await session.async_split_pane(
298
- vertical=vertical,
299
- before=before,
300
- profile=profile,
301
- profile_customizations=profile_customizations,
302
- )
355
+ # Build kwargs conditionally - only include profile if explicitly set
356
+ kwargs: dict = {"vertical": vertical, "before": before}
357
+ if profile is not None:
358
+ kwargs["profile"] = profile
359
+ if profile_customizations is not None:
360
+ kwargs["profile_customizations"] = profile_customizations
361
+
362
+ return await session.async_split_pane(**kwargs)
303
363
 
304
364
 
305
365
  async def close_pane(session: "ItermSession", force: bool = False) -> bool:
@@ -452,10 +512,77 @@ async def wait_for_claude_ready(
452
512
  return False
453
513
 
454
514
 
515
+ async def wait_for_agent_ready(
516
+ session: "ItermSession",
517
+ cli: "AgentCLI",
518
+ timeout_seconds: float = 15.0,
519
+ poll_interval: float = 0.2,
520
+ stable_count: int = 2,
521
+ ) -> bool:
522
+ """
523
+ Wait for an agent CLI to be ready to accept input.
524
+
525
+ Generic version of wait_for_claude_ready() that uses the CLI's ready_patterns.
526
+ Polls the screen content and waits for any of the CLI's ready patterns to appear.
527
+
528
+ Args:
529
+ session: iTerm2 session to monitor
530
+ cli: The AgentCLI instance providing ready_patterns
531
+ timeout_seconds: Maximum time to wait for readiness
532
+ poll_interval: Time between screen content checks
533
+ stable_count: Number of consecutive stable reads before considering ready
534
+
535
+ Returns:
536
+ True if agent became ready, False if timeout was reached
537
+ """
538
+ import asyncio
539
+ import time
540
+
541
+ patterns = cli.ready_patterns()
542
+ start_time = time.monotonic()
543
+ last_content = None
544
+ stable_reads = 0
545
+
546
+ while (time.monotonic() - start_time) < timeout_seconds:
547
+ try:
548
+ content = await read_screen_text(session)
549
+ lines = content.split('\n')
550
+
551
+ # Check if content is stable (same as last read)
552
+ if content == last_content:
553
+ stable_reads += 1
554
+ else:
555
+ stable_reads = 0
556
+ last_content = content
557
+
558
+ # Only check for readiness after content has stabilized
559
+ if stable_reads >= stable_count:
560
+ for line in lines:
561
+ stripped = line.strip()
562
+ for pattern in patterns:
563
+ if pattern in stripped:
564
+ logger.debug(
565
+ f"Agent ready: found pattern '{pattern}' in line"
566
+ )
567
+ return True
568
+
569
+ except Exception as e:
570
+ # Screen read failed, retry
571
+ logger.debug(f"Screen read failed during agent ready check: {e}")
572
+
573
+ await asyncio.sleep(poll_interval)
574
+
575
+ logger.warning(
576
+ f"Timeout waiting for {cli.engine_id} readiness ({timeout_seconds}s)"
577
+ )
578
+ return False
579
+
580
+
455
581
  # =============================================================================
456
- # Claude Session Control
582
+ # Agent Session Control
457
583
  # =============================================================================
458
584
 
585
+
459
586
  def build_stop_hook_settings_file(marker_id: str) -> str:
460
587
  """
461
588
  Build a settings file for Stop hook injection.
@@ -500,6 +627,84 @@ def build_stop_hook_settings_file(marker_id: str) -> str:
500
627
  return str(settings_file)
501
628
 
502
629
 
630
+ async def start_agent_in_session(
631
+ session: "ItermSession",
632
+ cli: "AgentCLI",
633
+ project_path: str,
634
+ dangerously_skip_permissions: bool = False,
635
+ env: Optional[dict[str, str]] = None,
636
+ shell_ready_timeout: float = 10.0,
637
+ agent_ready_timeout: float = 30.0,
638
+ stop_hook_marker_id: Optional[str] = None,
639
+ output_capture_path: Optional[str] = None,
640
+ ) -> None:
641
+ """
642
+ Start an agent CLI in an existing iTerm2 session.
643
+
644
+ Changes to the project directory and launches the agent in a single
645
+ atomic command (cd && <agent>). Waits for shell readiness before sending
646
+ the command, then waits for the agent's ready patterns to appear.
647
+
648
+ Args:
649
+ session: iTerm2 session to use
650
+ cli: AgentCLI instance defining command and arguments
651
+ project_path: Directory to run the agent in
652
+ dangerously_skip_permissions: If True, add skip-permissions flag
653
+ env: Optional dict of environment variables to set before running agent
654
+ shell_ready_timeout: Max seconds to wait for shell prompt
655
+ agent_ready_timeout: Max seconds to wait for agent to start
656
+ stop_hook_marker_id: If provided, inject a Stop hook for completion detection
657
+ (only used if cli.supports_settings_file() returns True)
658
+ output_capture_path: If provided, capture agent's stdout/stderr to this file
659
+ using tee. Useful for agents that output JSONL for idle detection.
660
+
661
+ Raises:
662
+ RuntimeError: If shell not ready or agent fails to start within timeout
663
+ """
664
+ # Wait for shell to be ready
665
+ shell_ready = await wait_for_shell_ready(session, timeout_seconds=shell_ready_timeout)
666
+ if not shell_ready:
667
+ raise RuntimeError(
668
+ f"Shell not ready after {shell_ready_timeout}s in {project_path}. "
669
+ "Terminal may still be initializing."
670
+ )
671
+
672
+ # Build settings file for Stop hook injection if supported
673
+ settings_file = None
674
+ if stop_hook_marker_id and cli.supports_settings_file():
675
+ settings_file = build_stop_hook_settings_file(stop_hook_marker_id)
676
+
677
+ # Build the full command using the AgentCLI abstraction
678
+ agent_cmd = cli.build_full_command(
679
+ dangerously_skip_permissions=dangerously_skip_permissions,
680
+ settings_file=settings_file,
681
+ env_vars=env,
682
+ )
683
+
684
+ # Add output capture via tee if requested
685
+ # This pipes stdout/stderr to both the terminal and a file (for JSONL parsing)
686
+ if output_capture_path:
687
+ agent_cmd = f"{agent_cmd} 2>&1 | tee {output_capture_path}"
688
+
689
+ # Combine cd and agent into atomic command to avoid race condition.
690
+ # Shell executes "cd /path && agent" as a unit - if cd fails, agent won't run.
691
+ cmd = f"cd {project_path} && {agent_cmd}"
692
+
693
+ logger.info(f"start_agent_in_session: Running command: {cmd[:200]}...")
694
+
695
+ await send_prompt(session, cmd)
696
+
697
+ # Wait for agent to actually start (detect ready patterns, not blind sleep)
698
+ if not await wait_for_agent_ready(
699
+ session, cli, timeout_seconds=agent_ready_timeout
700
+ ):
701
+ raise RuntimeError(
702
+ f"{cli.engine_id} failed to start in {project_path} within "
703
+ f"{agent_ready_timeout}s. Check that '{cli.command()}' command is "
704
+ "available and authentication is configured."
705
+ )
706
+
707
+
503
708
  async def start_claude_in_session(
504
709
  session: "ItermSession",
505
710
  project_path: str,
@@ -512,6 +717,7 @@ async def start_claude_in_session(
512
717
  """
513
718
  Start Claude Code in an existing iTerm2 session.
514
719
 
720
+ Convenience wrapper around start_agent_in_session() for Claude.
515
721
  Changes to the project directory and launches Claude Code in a single
516
722
  atomic command (cd && claude). Waits for shell readiness before sending
517
723
  the command, then waits for Claude's startup banner to appear.
@@ -534,50 +740,38 @@ async def start_claude_in_session(
534
740
  Raises:
535
741
  RuntimeError: If shell not ready or Claude fails to start within timeout
536
742
  """
537
- # Wait for shell to be ready
538
- shell_ready = await wait_for_shell_ready(session, timeout_seconds=shell_ready_timeout)
539
- if not shell_ready:
540
- raise RuntimeError(
541
- f"Shell not ready after {shell_ready_timeout}s in {project_path}. "
542
- "Terminal may still be initializing."
543
- )
544
-
545
- # Build claude command with flags
546
- # Allow overriding the claude command via environment variable (e.g., "happy")
547
- claude_cmd = os.environ.get("CLAUDE_TEAM_COMMAND", "claude")
548
- is_default_claude_command = claude_cmd == "claude"
549
-
550
- if dangerously_skip_permissions:
551
- claude_cmd += " --dangerously-skip-permissions"
552
-
553
- # Only add --settings for the default 'claude' command.
554
- # Custom commands like 'happy' have their own session tracking mechanisms
555
- # (e.g., Happy uses a SessionStart hook for mobile app integration).
556
- # Adding our --settings flag conflicts with theirs because Claude only
557
- # uses the first --settings file, breaking their session tracking.
558
- # See HAPPY_INTEGRATION_RESEARCH.md for full analysis.
559
- if stop_hook_marker_id and is_default_claude_command:
560
- settings_file = build_stop_hook_settings_file(stop_hook_marker_id)
561
- claude_cmd += f" --settings {settings_file}"
562
-
563
- # Prepend environment variables to claude (not cd)
564
- if env:
565
- env_exports = " ".join(f"{k}={v}" for k, v in env.items())
566
- claude_cmd = f"{env_exports} {claude_cmd}"
567
-
568
- # Combine cd and claude into atomic command to avoid race condition.
569
- # Shell executes "cd /path && claude" as a unit - if cd fails, claude won't run.
570
- # This eliminates the need for a second wait_for_shell_ready after cd.
571
- cmd = f"cd {project_path} && {claude_cmd}"
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
+ )
572
755
 
573
- await send_prompt(session, cmd)
574
756
 
575
- # Wait for Claude to actually start (detect banner, not blind sleep)
576
- if not await wait_for_claude_ready(session, timeout_seconds=claude_ready_timeout):
577
- raise RuntimeError(
578
- f"Claude failed to start in {project_path} within {claude_ready_timeout}s. "
579
- "Check that 'claude' command is available and authentication is configured."
580
- )
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
+ )
581
775
 
582
776
 
583
777
  # =============================================================================
claude_team_mcp/names.py CHANGED
@@ -119,6 +119,9 @@ DUOS: dict[str, list[str]] = {
119
119
  "tia_tamera": ["Tia", "Tamera"],
120
120
  "statler_waldorf": ["Statler", "Waldorf"],
121
121
  "laverne_shirley": ["Laverne", "Shirley"],
122
+ # Star Trek
123
+ "kirk_khan": ["Kirk", "Khan"],
124
+ "picard_q": ["Picard", "Q"],
122
125
  }
123
126
 
124
127
  # Terrific trios - famous threesomes
@@ -10,13 +10,16 @@ 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, Optional
13
+ from typing import TYPE_CHECKING, Literal, Optional
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from iterm2.session import Session as ItermSession
17
17
 
18
18
  from .session_state import get_project_dir, parse_session
19
19
 
20
+ # Type alias for supported agent types
21
+ AgentType = Literal["claude", "codex"]
22
+
20
23
 
21
24
  @dataclass(frozen=True)
22
25
  class TerminalId:
@@ -87,6 +90,9 @@ class ManagedSession:
87
90
  # Terminal-agnostic identifier (auto-populated from iterm_session if not set)
88
91
  terminal_id: Optional[TerminalId] = None
89
92
 
93
+ # Agent type: "claude" (default) or "codex"
94
+ agent_type: AgentType = "claude"
95
+
90
96
  def __post_init__(self):
91
97
  """Auto-populate terminal_id from iterm_session if not set."""
92
98
  if self.terminal_id is None and self.iterm_session is not None:
@@ -94,7 +100,7 @@ class ManagedSession:
94
100
 
95
101
  def to_dict(self) -> dict:
96
102
  """Convert to dictionary for MCP tool responses."""
97
- return {
103
+ result = {
98
104
  "session_id": self.session_id,
99
105
  "terminal_id": str(self.terminal_id) if self.terminal_id else None,
100
106
  "name": self.name or self.session_id,
@@ -106,7 +112,9 @@ class ManagedSession:
106
112
  "coordinator_annotation": self.coordinator_annotation,
107
113
  "worktree_path": str(self.worktree_path) if self.worktree_path else None,
108
114
  "main_repo_path": str(self.main_repo_path) if self.main_repo_path else None,
115
+ "agent_type": self.agent_type,
109
116
  }
117
+ return result
110
118
 
111
119
  def update_activity(self) -> None:
112
120
  """Update the last_activity timestamp."""
@@ -139,47 +147,77 @@ class ManagedSession:
139
147
  """
140
148
  Get the path to this session's JSONL file.
141
149
 
142
- Automatically tries to discover the session if not already known.
150
+ For Claude workers: uses marker-based discovery in ~/.claude/projects/.
151
+ For Codex workers: searches ~/.codex/sessions/ for matching session files.
143
152
 
144
153
  Returns:
145
154
  Path object, or None if session cannot be discovered
146
155
  """
147
- # Auto-discover if not already known (using marker-based discovery)
148
- if not self.claude_session_id:
149
- self.discover_claude_session_by_marker()
156
+ if self.agent_type == "codex":
157
+ # For Codex, search the sessions directory
158
+ from .idle_detection import find_codex_session_file
150
159
 
151
- if not self.claude_session_id:
152
- return None
153
- return get_project_dir(self.project_path) / f"{self.claude_session_id}.jsonl"
160
+ return find_codex_session_file(max_age_seconds=600)
161
+ else:
162
+ # For Claude, use marker-based discovery
163
+ # Auto-discover if not already known
164
+ if not self.claude_session_id:
165
+ self.discover_claude_session_by_marker()
166
+
167
+ if not self.claude_session_id:
168
+ return None
169
+ return get_project_dir(self.project_path) / f"{self.claude_session_id}.jsonl"
154
170
 
155
171
  def get_conversation_state(self):
156
172
  """
157
173
  Parse and return the current conversation state.
158
174
 
175
+ For Claude workers: uses parse_session() for Claude's JSONL format.
176
+ For Codex workers: uses parse_codex_session() for Codex's JSONL format.
177
+
159
178
  Returns:
160
179
  SessionState object, or None if JSONL not available
161
180
  """
162
181
  jsonl_path = self.get_jsonl_path()
163
182
  if not jsonl_path or not jsonl_path.exists():
164
183
  return None
165
- return parse_session(jsonl_path)
184
+
185
+ if self.agent_type == "codex":
186
+ from .session_state import parse_codex_session
187
+
188
+ return parse_codex_session(jsonl_path)
189
+ else:
190
+ return parse_session(jsonl_path)
166
191
 
167
192
  def is_idle(self) -> bool:
168
193
  """
169
- Check if this session is idle using stop hook detection.
194
+ Check if this session is idle.
195
+
196
+ For Claude: Uses stop hook detection - session is idle if its Stop hook
197
+ has fired and no messages have been sent after it.
170
198
 
171
- A session is idle if its Stop hook has fired and no messages
172
- have been sent after it.
199
+ For Codex: Searches ~/.codex/sessions/ for the session file and checks
200
+ for agent_message events which indicate the agent finished responding.
173
201
 
174
202
  Returns:
175
- True if idle, False if working or JSONL not available
203
+ True if idle, False if working or session file not available
176
204
  """
177
- from .idle_detection import is_idle as check_is_idle
178
-
179
- jsonl_path = self.get_jsonl_path()
180
- if not jsonl_path or not jsonl_path.exists():
181
- return False
182
- return check_is_idle(jsonl_path, self.session_id)
205
+ if self.agent_type == "codex":
206
+ from .idle_detection import find_codex_session_file, is_codex_idle
207
+
208
+ # Find the session file (will be discovered from ~/.codex/sessions/)
209
+ session_file = find_codex_session_file(max_age_seconds=600)
210
+ if not session_file:
211
+ return False
212
+ return is_codex_idle(session_file)
213
+ else:
214
+ # Default: Claude Code with Stop hook detection
215
+ from .idle_detection import is_idle as check_is_idle
216
+
217
+ jsonl_path = self.get_jsonl_path()
218
+ if not jsonl_path or not jsonl_path.exists():
219
+ return False
220
+ return check_is_idle(jsonl_path, self.session_id)
183
221
 
184
222
  def get_conversation_stats(self) -> dict | None:
185
223
  """
@@ -0,0 +1,5 @@
1
+ """Schema definitions for parsing CLI output formats."""
2
+
3
+ from . import codex
4
+
5
+ __all__ = ["codex"]