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.
- claude_team_mcp/cli_backends/__init__.py +44 -0
- claude_team_mcp/cli_backends/base.py +132 -0
- claude_team_mcp/cli_backends/claude.py +110 -0
- claude_team_mcp/cli_backends/codex.py +110 -0
- claude_team_mcp/formatting.py +16 -1
- claude_team_mcp/idle_detection.py +289 -9
- claude_team_mcp/iterm_utils.py +256 -62
- claude_team_mcp/names.py +3 -0
- claude_team_mcp/registry.py +58 -20
- claude_team_mcp/schemas/__init__.py +5 -0
- claude_team_mcp/schemas/codex.py +267 -0
- claude_team_mcp/server.py +8 -1
- claude_team_mcp/session_state.py +214 -0
- claude_team_mcp/tools/message_workers.py +110 -16
- claude_team_mcp/tools/spawn_workers.py +81 -48
- claude_team_mcp/utils/constants.py +6 -0
- claude_team_mcp/worker_prompt.py +201 -15
- claude_team_mcp/worktree.py +29 -20
- {claude_team_mcp-0.3.2.dist-info → claude_team_mcp-0.5.0.dist-info}/METADATA +27 -3
- {claude_team_mcp-0.3.2.dist-info → claude_team_mcp-0.5.0.dist-info}/RECORD +22 -16
- {claude_team_mcp-0.3.2.dist-info → claude_team_mcp-0.5.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.3.2.dist-info → claude_team_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
|
@@ -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.
|
|
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
|
# =============================================================================
|
claude_team_mcp/session_state.py
CHANGED
|
@@ -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
|
|
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
|
|
127
|
-
|
|
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
|
-
#
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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:
|