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
claude_team_mcp/iterm_utils.py
CHANGED
|
@@ -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
|
-
#
|
|
236
|
-
#
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
profile=profile
|
|
301
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
claude_team_mcp/registry.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
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
|
|
203
|
+
True if idle, False if working or session file not available
|
|
176
204
|
"""
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
"""
|