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.
@@ -0,0 +1,267 @@
1
+ """Codex CLI JSONL schema for parsing headless mode output.
2
+
3
+ Schema derived from Codex rust-v0.77.0 (git 112f40e91c12af0f7146d7e03f20283516a8af0b).
4
+ Ported from takopi/src/takopi/schemas/codex.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal, TypeAlias
10
+
11
+ import msgspec
12
+
13
+ # Status type aliases for various item types
14
+ CommandExecutionStatus: TypeAlias = Literal[
15
+ "in_progress",
16
+ "completed",
17
+ "failed",
18
+ "declined",
19
+ ]
20
+
21
+ PatchApplyStatus: TypeAlias = Literal[
22
+ "in_progress",
23
+ "completed",
24
+ "failed",
25
+ ]
26
+
27
+ PatchChangeKind: TypeAlias = Literal[
28
+ "add",
29
+ "delete",
30
+ "update",
31
+ ]
32
+
33
+ McpToolCallStatus: TypeAlias = Literal[
34
+ "in_progress",
35
+ "completed",
36
+ "failed",
37
+ ]
38
+
39
+
40
+ # --- Core structs ---
41
+
42
+
43
+ class Usage(msgspec.Struct, kw_only=True):
44
+ """Token usage statistics for a turn."""
45
+
46
+ input_tokens: int
47
+ cached_input_tokens: int
48
+ output_tokens: int
49
+
50
+
51
+ class ThreadError(msgspec.Struct, kw_only=True):
52
+ """Error information for failed turns."""
53
+
54
+ message: str
55
+
56
+
57
+ # --- Thread lifecycle events ---
58
+
59
+
60
+ class ThreadStarted(msgspec.Struct, tag="thread.started", kw_only=True):
61
+ """Emitted when a new Codex thread begins. Contains thread_id for session tracking."""
62
+
63
+ thread_id: str
64
+
65
+
66
+ class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
67
+ """Emitted when a new turn begins within a thread."""
68
+
69
+ pass
70
+
71
+
72
+ class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
73
+ """Emitted when a turn completes successfully. Contains usage statistics."""
74
+
75
+ usage: Usage
76
+
77
+
78
+ class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
79
+ """Emitted when a turn fails. Contains error details."""
80
+
81
+ error: ThreadError
82
+
83
+
84
+ class StreamError(msgspec.Struct, tag="error", kw_only=True):
85
+ """Emitted for stream-level errors (e.g., reconnection attempts)."""
86
+
87
+ message: str
88
+
89
+
90
+ # --- Item types (tools, messages, etc.) ---
91
+
92
+
93
+ class AgentMessageItem(msgspec.Struct, tag="agent_message", kw_only=True):
94
+ """Text message from the agent."""
95
+
96
+ id: str
97
+ text: str
98
+
99
+
100
+ class ReasoningItem(msgspec.Struct, tag="reasoning", kw_only=True):
101
+ """Reasoning/thinking output from the agent."""
102
+
103
+ id: str
104
+ text: str
105
+
106
+
107
+ class CommandExecutionItem(msgspec.Struct, tag="command_execution", kw_only=True):
108
+ """Shell command execution."""
109
+
110
+ id: str
111
+ command: str
112
+ aggregated_output: str
113
+ exit_code: int | None
114
+ status: CommandExecutionStatus
115
+
116
+
117
+ class FileUpdateChange(msgspec.Struct, kw_only=True):
118
+ """A single file change within a patch."""
119
+
120
+ path: str
121
+ kind: PatchChangeKind
122
+
123
+
124
+ class FileChangeItem(msgspec.Struct, tag="file_change", kw_only=True):
125
+ """File modification/patch operation."""
126
+
127
+ id: str
128
+ changes: list[FileUpdateChange]
129
+ status: PatchApplyStatus
130
+
131
+
132
+ class McpToolCallItemResult(msgspec.Struct, kw_only=True):
133
+ """Result of an MCP tool call."""
134
+
135
+ content: list[dict[str, Any]]
136
+ structured_content: Any
137
+
138
+
139
+ class McpToolCallItemError(msgspec.Struct, kw_only=True):
140
+ """Error from an MCP tool call."""
141
+
142
+ message: str
143
+
144
+
145
+ class McpToolCallItem(msgspec.Struct, tag="mcp_tool_call", kw_only=True):
146
+ """MCP tool invocation."""
147
+
148
+ id: str
149
+ server: str
150
+ tool: str
151
+ arguments: Any
152
+ result: McpToolCallItemResult | None
153
+ error: McpToolCallItemError | None
154
+ status: McpToolCallStatus
155
+
156
+
157
+ class WebSearchItem(msgspec.Struct, tag="web_search", kw_only=True):
158
+ """Web search operation."""
159
+
160
+ id: str
161
+ query: str
162
+
163
+
164
+ class ErrorItem(msgspec.Struct, tag="error", kw_only=True):
165
+ """Error item (distinct from StreamError - this is an item in the thread)."""
166
+
167
+ id: str
168
+ message: str
169
+
170
+
171
+ class TodoItem(msgspec.Struct, kw_only=True):
172
+ """A single todo item."""
173
+
174
+ text: str
175
+ completed: bool
176
+
177
+
178
+ class TodoListItem(msgspec.Struct, tag="todo_list", kw_only=True):
179
+ """Todo list from the agent."""
180
+
181
+ id: str
182
+ items: list[TodoItem]
183
+
184
+
185
+ # Union of all possible thread items
186
+ ThreadItem: TypeAlias = (
187
+ AgentMessageItem
188
+ | ReasoningItem
189
+ | CommandExecutionItem
190
+ | FileChangeItem
191
+ | McpToolCallItem
192
+ | WebSearchItem
193
+ | TodoListItem
194
+ | ErrorItem
195
+ )
196
+
197
+
198
+ # --- Item lifecycle events ---
199
+
200
+
201
+ class ItemStarted(msgspec.Struct, tag="item.started", kw_only=True):
202
+ """Emitted when an item (tool use, message, etc.) starts."""
203
+
204
+ item: ThreadItem
205
+
206
+
207
+ class ItemUpdated(msgspec.Struct, tag="item.updated", kw_only=True):
208
+ """Emitted when an item is updated (streaming content)."""
209
+
210
+ item: ThreadItem
211
+
212
+
213
+ class ItemCompleted(msgspec.Struct, tag="item.completed", kw_only=True):
214
+ """Emitted when an item finishes."""
215
+
216
+ item: ThreadItem
217
+
218
+
219
+ # Union of all possible thread events
220
+ ThreadEvent: TypeAlias = (
221
+ ThreadStarted
222
+ | TurnStarted
223
+ | TurnCompleted
224
+ | TurnFailed
225
+ | ItemStarted
226
+ | ItemUpdated
227
+ | ItemCompleted
228
+ | StreamError
229
+ )
230
+
231
+ # Pre-constructed decoder for efficient parsing
232
+ _DECODER = msgspec.json.Decoder(ThreadEvent)
233
+
234
+
235
+ def decode_event(data: bytes | str) -> ThreadEvent:
236
+ """Decode a single JSONL line into a ThreadEvent.
237
+
238
+ Args:
239
+ data: Raw JSON bytes or string from Codex JSONL output
240
+
241
+ Returns:
242
+ Parsed ThreadEvent (one of the union member types)
243
+
244
+ Raises:
245
+ msgspec.DecodeError: If the JSON is malformed or doesn't match schema
246
+ """
247
+ return _DECODER.decode(data)
248
+
249
+
250
+ # --- Helper functions for common operations ---
251
+
252
+
253
+ def is_turn_complete(event: ThreadEvent) -> bool:
254
+ """Check if the event indicates a turn has finished (success or failure)."""
255
+ return isinstance(event, (TurnCompleted, TurnFailed))
256
+
257
+
258
+ def is_turn_successful(event: ThreadEvent) -> bool:
259
+ """Check if the event is a successful turn completion."""
260
+ return isinstance(event, TurnCompleted)
261
+
262
+
263
+ def get_thread_id(event: ThreadEvent) -> str | None:
264
+ """Extract thread_id from a ThreadStarted event, or None for other events."""
265
+ if isinstance(event, ThreadStarted):
266
+ return event.thread_id
267
+ return None
claude_team_mcp/server.py CHANGED
@@ -26,10 +26,17 @@ from .utils import error_response, HINTS
26
26
 
27
27
  # Configure logging
28
28
  logging.basicConfig(
29
- level=logging.INFO,
29
+ level=logging.DEBUG,
30
30
  format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
31
31
  )
32
32
  logger = logging.getLogger("claude-team-mcp")
33
+ # Add file handler for debugging
34
+ _fh = logging.FileHandler("/tmp/claude-team-debug.log")
35
+ _fh.setLevel(logging.DEBUG)
36
+ _fh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s"))
37
+ logger.addHandler(_fh)
38
+ logging.getLogger().addHandler(_fh) # Also capture root logger
39
+ logger.info("=== Claude Team MCP Server Starting ===")
33
40
 
34
41
 
35
42
  # =============================================================================
@@ -629,6 +629,220 @@ def parse_session(jsonl_path: Path) -> SessionState:
629
629
  )
630
630
 
631
631
 
632
+ # =============================================================================
633
+ # Codex Session Parsing
634
+ # =============================================================================
635
+
636
+
637
+ def parse_codex_session(jsonl_path: Path) -> SessionState:
638
+ """
639
+ Parse a Codex session JSONL file into a SessionState object.
640
+
641
+ Codex has a different JSONL format than Claude Code:
642
+ - Interactive mode uses event_msg and response_item wrappers
643
+ - Exec mode uses direct ThreadEvent types (item.completed, etc.)
644
+
645
+ Args:
646
+ jsonl_path: Path to the Codex JSONL file
647
+
648
+ Returns:
649
+ Parsed SessionState object with messages extracted
650
+ """
651
+ messages = []
652
+ session_id = jsonl_path.stem
653
+
654
+ try:
655
+ with open(jsonl_path, "r") as f:
656
+ for line_num, line in enumerate(f):
657
+ line = line.strip()
658
+ if not line:
659
+ continue
660
+
661
+ try:
662
+ data = json.loads(line)
663
+ except json.JSONDecodeError:
664
+ continue
665
+
666
+ msg = _parse_codex_event(data, line_num)
667
+ if msg:
668
+ messages.append(msg)
669
+
670
+ except FileNotFoundError:
671
+ pass
672
+
673
+ return SessionState(
674
+ session_id=session_id,
675
+ project_path="", # Codex doesn't have project path in the same way
676
+ jsonl_path=jsonl_path,
677
+ messages=messages,
678
+ last_modified=jsonl_path.stat().st_mtime if jsonl_path.exists() else 0,
679
+ )
680
+
681
+
682
+ def _parse_codex_event(data: dict, line_num: int) -> Optional[Message]:
683
+ """
684
+ Parse a single Codex JSONL event into a Message if applicable.
685
+
686
+ Handles both interactive mode format (wrapped events) and exec mode format
687
+ (direct ThreadEvent types).
688
+
689
+ Args:
690
+ data: Parsed JSON dict from JSONL line
691
+ line_num: Line number for UUID generation
692
+
693
+ Returns:
694
+ Message object if this event represents a message, None otherwise
695
+ """
696
+ event_type = data.get("type", "")
697
+ now = datetime.now()
698
+
699
+ # Interactive mode: event_msg wrapper
700
+ if event_type == "event_msg":
701
+ payload = data.get("payload", {})
702
+ payload_type = payload.get("type")
703
+
704
+ if payload_type == "agent_message":
705
+ # Agent response message
706
+ text = payload.get("text", "")
707
+ if text:
708
+ return Message(
709
+ uuid=payload.get("id", f"codex-{line_num}"),
710
+ parent_uuid=None,
711
+ role="assistant",
712
+ content=text,
713
+ timestamp=now,
714
+ )
715
+
716
+ elif payload_type == "user_message":
717
+ # User input message
718
+ text = payload.get("text", "")
719
+ if text:
720
+ return Message(
721
+ uuid=payload.get("id", f"codex-user-{line_num}"),
722
+ parent_uuid=None,
723
+ role="user",
724
+ content=text,
725
+ timestamp=now,
726
+ )
727
+
728
+ # Interactive mode: response_item wrapper
729
+ elif event_type == "response_item":
730
+ payload = data.get("payload", {})
731
+ payload_type = payload.get("type")
732
+ role = payload.get("role", "")
733
+
734
+ if payload_type == "message":
735
+ # Extract content from the message
736
+ content_list = payload.get("content", [])
737
+ text_parts = []
738
+ for item in content_list:
739
+ if isinstance(item, dict) and item.get("type") == "output_text":
740
+ text_parts.append(item.get("text", ""))
741
+ elif isinstance(item, dict) and item.get("type") == "input_text":
742
+ text_parts.append(item.get("text", ""))
743
+ elif isinstance(item, dict) and item.get("type") == "text":
744
+ text_parts.append(item.get("text", ""))
745
+
746
+ text = "".join(text_parts)
747
+ if text:
748
+ return Message(
749
+ uuid=payload.get("id", f"codex-resp-{line_num}"),
750
+ parent_uuid=None,
751
+ role=role if role else "assistant",
752
+ content=text,
753
+ timestamp=now,
754
+ )
755
+
756
+ elif payload_type == "agent_message":
757
+ # Direct agent_message in payload
758
+ text = payload.get("text", "")
759
+ if text:
760
+ return Message(
761
+ uuid=payload.get("id", f"codex-agent-{line_num}"),
762
+ parent_uuid=None,
763
+ role="assistant",
764
+ content=text,
765
+ timestamp=now,
766
+ )
767
+
768
+ # Exec mode: item.completed events
769
+ elif event_type == "item.completed":
770
+ item = data.get("item", {})
771
+ item_type = item.get("type")
772
+
773
+ if item_type == "agent_message":
774
+ text = item.get("text", "")
775
+ if text:
776
+ return Message(
777
+ uuid=item.get("id", f"codex-exec-{line_num}"),
778
+ parent_uuid=None,
779
+ role="assistant",
780
+ content=text,
781
+ timestamp=now,
782
+ )
783
+
784
+ elif item_type == "reasoning":
785
+ # Reasoning/thinking block
786
+ text = item.get("text", "")
787
+ if text:
788
+ return Message(
789
+ uuid=item.get("id", f"codex-think-{line_num}"),
790
+ parent_uuid=None,
791
+ role="assistant",
792
+ content="", # Put reasoning in thinking field instead
793
+ timestamp=now,
794
+ thinking=text,
795
+ )
796
+
797
+ elif item_type == "command_execution":
798
+ # Shell command execution
799
+ cmd = item.get("command", "")
800
+ output = item.get("aggregated_output", "")
801
+ exit_code = item.get("exit_code")
802
+ status = item.get("status", "")
803
+ if cmd:
804
+ content = f"Command: {cmd}\n"
805
+ if output:
806
+ content += f"Output:\n{output}\n"
807
+ if exit_code is not None:
808
+ content += f"Exit code: {exit_code}"
809
+ return Message(
810
+ uuid=item.get("id", f"codex-cmd-{line_num}"),
811
+ parent_uuid=None,
812
+ role="assistant",
813
+ content=content,
814
+ timestamp=now,
815
+ tool_uses=[{
816
+ "name": "command_execution",
817
+ "input": {"command": cmd, "status": status},
818
+ }],
819
+ )
820
+
821
+ elif item_type == "file_change":
822
+ # File modification
823
+ changes = item.get("changes", [])
824
+ if changes:
825
+ change_lines = []
826
+ for c in changes:
827
+ path = c.get("path", "")
828
+ kind = c.get("kind", "")
829
+ change_lines.append(f" {kind}: {path}")
830
+ content = "File changes:\n" + "\n".join(change_lines)
831
+ return Message(
832
+ uuid=item.get("id", f"codex-file-{line_num}"),
833
+ parent_uuid=None,
834
+ role="assistant",
835
+ content=content,
836
+ timestamp=now,
837
+ tool_uses=[{
838
+ "name": "file_change",
839
+ "input": {"changes": changes},
840
+ }],
841
+ )
842
+
843
+ return None
844
+
845
+
632
846
  # =============================================================================
633
847
  # Stop Hook Detection
634
848
  # =============================================================================
@@ -19,13 +19,80 @@ from ..idle_detection import (
19
19
  wait_for_any_idle as wait_for_any_idle_impl,
20
20
  SessionInfo,
21
21
  )
22
- from ..iterm_utils import send_prompt
22
+ from ..iterm_utils import send_prompt_for_agent
23
23
  from ..registry import SessionStatus
24
24
  from ..utils import error_response, HINTS, WORKER_MESSAGE_HINT
25
25
 
26
26
  logger = logging.getLogger("claude-team-mcp")
27
27
 
28
28
 
29
+ async def _wait_for_sessions_idle(
30
+ sessions: list[tuple[str, object]],
31
+ mode: str,
32
+ timeout: float,
33
+ poll_interval: float = 2.0,
34
+ ) -> dict:
35
+ """
36
+ Wait for sessions to become idle using session.is_idle().
37
+
38
+ This unified waiting function works for both Claude and Codex sessions
39
+ by calling session.is_idle() which internally handles agent-specific
40
+ idle detection.
41
+
42
+ Args:
43
+ sessions: List of (session_id, ManagedSession) tuples
44
+ mode: "any" or "all"
45
+ timeout: Maximum seconds to wait
46
+ poll_interval: Seconds between checks
47
+
48
+ Returns:
49
+ Dict with idle_session_ids, all_idle, timed_out
50
+ """
51
+ import time
52
+
53
+ start = time.time()
54
+
55
+ while time.time() - start < timeout:
56
+ idle_sessions = []
57
+ working_sessions = []
58
+
59
+ for sid, session in sessions:
60
+ if session.is_idle():
61
+ idle_sessions.append(sid)
62
+ else:
63
+ working_sessions.append(sid)
64
+
65
+ if mode == "any" and idle_sessions:
66
+ return {
67
+ "idle_session_ids": idle_sessions,
68
+ "all_idle": len(working_sessions) == 0,
69
+ "timed_out": False,
70
+ }
71
+ elif mode == "all" and not working_sessions:
72
+ return {
73
+ "idle_session_ids": idle_sessions,
74
+ "all_idle": True,
75
+ "timed_out": False,
76
+ }
77
+
78
+ await asyncio.sleep(poll_interval)
79
+
80
+ # Timeout - return final state
81
+ idle_sessions = []
82
+ working_sessions = []
83
+ for sid, session in sessions:
84
+ if session.is_idle():
85
+ idle_sessions.append(sid)
86
+ else:
87
+ working_sessions.append(sid)
88
+
89
+ return {
90
+ "idle_session_ids": idle_sessions,
91
+ "all_idle": len(working_sessions) == 0,
92
+ "timed_out": True,
93
+ }
94
+
95
+
29
96
  def register_tools(mcp: FastMCP) -> None:
30
97
  """Register message_workers tool on the MCP server."""
31
98
 
@@ -123,8 +190,14 @@ def register_tools(mcp: FastMCP) -> None:
123
190
  # Append hint about bd_help tool to help workers understand beads
124
191
  message_with_hint = message + WORKER_MESSAGE_HINT
125
192
 
126
- # Send the message to the terminal
127
- await send_prompt(session.iterm_session, message_with_hint, submit=True)
193
+ # Send the message using agent-specific input handling.
194
+ # Codex needs a longer pre-Enter delay than Claude.
195
+ await send_prompt_for_agent(
196
+ session.iterm_session,
197
+ message_with_hint,
198
+ agent_type=session.agent_type,
199
+ submit=True,
200
+ )
128
201
 
129
202
  return (sid, {
130
203
  "success": True,
@@ -171,17 +244,38 @@ def register_tools(mcp: FastMCP) -> None:
171
244
  # appears idle from the previous stop hook, causing us to return prematurely.
172
245
  await asyncio.sleep(0.5)
173
246
 
174
- # Get session infos for idle detection
175
- session_infos = []
247
+ # Separate sessions by agent type for different idle detection methods
248
+ claude_sessions = []
249
+ codex_sessions = []
176
250
  for sid, session in valid_sessions:
177
- jsonl_path = session.get_jsonl_path()
178
- if jsonl_path:
179
- session_infos.append(SessionInfo(
180
- jsonl_path=jsonl_path,
181
- session_id=session.session_id,
182
- ))
183
-
184
- if session_infos:
251
+ if session.agent_type == "codex":
252
+ codex_sessions.append((sid, session))
253
+ else:
254
+ # Claude sessions use JSONL-based SessionInfo
255
+ jsonl_path = session.get_jsonl_path()
256
+ if jsonl_path:
257
+ claude_sessions.append((sid, session, jsonl_path))
258
+
259
+ # Build session infos for Claude sessions
260
+ session_infos = [
261
+ SessionInfo(jsonl_path=jsonl_path, session_id=sid)
262
+ for sid, session, jsonl_path in claude_sessions
263
+ ]
264
+
265
+ # For mixed sessions, use unified polling via session.is_idle()
266
+ if codex_sessions or not session_infos:
267
+ # Use session.is_idle() which handles both Claude and Codex
268
+ idle_result = await _wait_for_sessions_idle(
269
+ sessions=[(sid, session) for sid, session in valid_sessions],
270
+ mode=wait_mode,
271
+ timeout=timeout,
272
+ poll_interval=2.0,
273
+ )
274
+ result["idle_session_ids"] = idle_result.get("idle_session_ids", [])
275
+ result["all_idle"] = idle_result.get("all_idle", False)
276
+ result["timed_out"] = idle_result.get("timed_out", False)
277
+ elif session_infos:
278
+ # Pure Claude sessions - use optimized Claude-specific waiting
185
279
  if wait_mode == "any":
186
280
  idle_result = await wait_for_any_idle_impl(
187
281
  sessions=session_infos,
@@ -205,9 +299,9 @@ def register_tools(mcp: FastMCP) -> None:
205
299
  result["all_idle"] = idle_result.get("all_idle", False)
206
300
  result["timed_out"] = idle_result.get("timed_out", False)
207
301
 
208
- # Update status for idle sessions
209
- for sid in result.get("idle_session_ids", []):
210
- registry.update_status(sid, SessionStatus.READY)
302
+ # Update status for idle sessions (applies to both paths)
303
+ for sid in result.get("idle_session_ids", []):
304
+ registry.update_status(sid, SessionStatus.READY)
211
305
  else:
212
306
  # No waiting - mark sessions as ready immediately
213
307
  for sid, session in valid_sessions: