claude-team-mcp 0.6.1__py3-none-any.whl → 0.8.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 +501 -0
- claude_team/idle_detection.py +173 -0
- claude_team/poller.py +245 -0
- claude_team_mcp/cli_backends/__init__.py +4 -2
- claude_team_mcp/cli_backends/claude.py +45 -5
- claude_team_mcp/cli_backends/codex.py +44 -3
- claude_team_mcp/config.py +350 -0
- claude_team_mcp/config_cli.py +263 -0
- claude_team_mcp/idle_detection.py +16 -3
- claude_team_mcp/issue_tracker/__init__.py +68 -3
- claude_team_mcp/iterm_utils.py +5 -73
- claude_team_mcp/registry.py +43 -26
- claude_team_mcp/server.py +164 -61
- claude_team_mcp/session_state.py +364 -2
- claude_team_mcp/terminal_backends/__init__.py +49 -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 +683 -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 +254 -153
- claude_team_mcp/tools/wait_idle_workers.py +1 -0
- claude_team_mcp/utils/errors.py +7 -3
- claude_team_mcp/worktree.py +73 -12
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
- claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
- claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
- {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -16,6 +16,7 @@ from . import examine_worker
|
|
|
16
16
|
from . import list_workers
|
|
17
17
|
from . import list_worktrees
|
|
18
18
|
from . import message_workers
|
|
19
|
+
from . import poll_worker_changes
|
|
19
20
|
from . import read_worker_logs
|
|
20
21
|
from . import spawn_workers
|
|
21
22
|
from . import wait_idle_workers
|
|
@@ -27,7 +28,7 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
27
28
|
|
|
28
29
|
Args:
|
|
29
30
|
mcp: The FastMCP server instance
|
|
30
|
-
ensure_connection: Function to ensure
|
|
31
|
+
ensure_connection: Function to ensure terminal backend is alive
|
|
31
32
|
"""
|
|
32
33
|
# Tools that don't need ensure_connection
|
|
33
34
|
annotate_worker.register_tools(mcp)
|
|
@@ -38,10 +39,11 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
38
39
|
list_workers.register_tools(mcp)
|
|
39
40
|
list_worktrees.register_tools(mcp)
|
|
40
41
|
message_workers.register_tools(mcp)
|
|
42
|
+
poll_worker_changes.register_tools(mcp)
|
|
41
43
|
read_worker_logs.register_tools(mcp)
|
|
42
44
|
wait_idle_workers.register_tools(mcp)
|
|
43
45
|
|
|
44
|
-
# Tools that need ensure_connection for
|
|
46
|
+
# Tools that need ensure_connection for terminal backend operations
|
|
45
47
|
adopt_worker.register_tools(mcp, ensure_connection)
|
|
46
48
|
discover_workers.register_tools(mcp, ensure_connection)
|
|
47
49
|
spawn_workers.register_tools(mcp, ensure_connection)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Adopt worker tool.
|
|
3
3
|
|
|
4
|
-
Provides adopt_worker for importing existing
|
|
4
|
+
Provides adopt_worker for importing existing terminal Claude Code and Codex sessions.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
@@ -15,7 +15,12 @@ if TYPE_CHECKING:
|
|
|
15
15
|
from ..server import AppContext
|
|
16
16
|
|
|
17
17
|
from ..registry import SessionStatus
|
|
18
|
-
from ..session_state import
|
|
18
|
+
from ..session_state import (
|
|
19
|
+
find_codex_session_by_iterm_id,
|
|
20
|
+
find_codex_session_by_tmux_id,
|
|
21
|
+
find_jsonl_by_iterm_id,
|
|
22
|
+
find_jsonl_by_tmux_id,
|
|
23
|
+
)
|
|
19
24
|
from ..utils import error_response, HINTS
|
|
20
25
|
|
|
21
26
|
logger = logging.getLogger("claude-team-mcp")
|
|
@@ -27,19 +32,21 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
27
32
|
@mcp.tool()
|
|
28
33
|
async def adopt_worker(
|
|
29
34
|
ctx: Context[ServerSession, "AppContext"],
|
|
30
|
-
iterm_session_id: str,
|
|
35
|
+
iterm_session_id: str | None = None,
|
|
36
|
+
tmux_pane_id: str | None = None,
|
|
31
37
|
session_name: str | None = None,
|
|
32
38
|
max_age: int = 3600,
|
|
33
39
|
) -> dict:
|
|
34
40
|
"""
|
|
35
|
-
Adopt an existing
|
|
41
|
+
Adopt an existing terminal Claude Code or Codex session into the MCP registry.
|
|
36
42
|
|
|
37
|
-
Takes
|
|
43
|
+
Takes a terminal session ID (from discover_workers) and registers it
|
|
38
44
|
for management. Only works for sessions originally spawned by claude-team
|
|
39
45
|
(which have markers in their JSONL for reliable correlation).
|
|
40
46
|
|
|
41
47
|
Args:
|
|
42
48
|
iterm_session_id: The iTerm2 session ID (from discover_workers)
|
|
49
|
+
tmux_pane_id: The tmux pane ID (from discover_workers)
|
|
43
50
|
session_name: Optional friendly name for the worker
|
|
44
51
|
max_age: Only check JSONL files modified within this many seconds (default 3600)
|
|
45
52
|
|
|
@@ -49,51 +56,99 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
49
56
|
app_ctx = ctx.request_context.lifespan_context
|
|
50
57
|
registry = app_ctx.registry
|
|
51
58
|
|
|
52
|
-
# Ensure we have a fresh connection
|
|
53
|
-
|
|
59
|
+
# Ensure we have a fresh backend connection/state
|
|
60
|
+
backend = await ensure_connection(app_ctx)
|
|
61
|
+
backend_id = backend.backend_id
|
|
62
|
+
|
|
63
|
+
target_id = iterm_session_id if backend_id == "iterm" else tmux_pane_id
|
|
64
|
+
if backend_id == "tmux" and not target_id:
|
|
65
|
+
target_id = iterm_session_id
|
|
66
|
+
if backend_id == "iterm" and not target_id:
|
|
67
|
+
target_id = tmux_pane_id
|
|
68
|
+
|
|
69
|
+
if not target_id:
|
|
70
|
+
return error_response(
|
|
71
|
+
"terminal session id is required",
|
|
72
|
+
hint="Pass iterm_session_id or tmux_pane_id from discover_workers",
|
|
73
|
+
)
|
|
54
74
|
|
|
55
75
|
# Check if already managed
|
|
56
76
|
for managed in registry.list_all():
|
|
57
|
-
if
|
|
77
|
+
if (
|
|
78
|
+
managed.terminal_session.backend_id == backend_id
|
|
79
|
+
and managed.terminal_session.native_id == target_id
|
|
80
|
+
):
|
|
58
81
|
return error_response(
|
|
59
82
|
f"Session already managed as '{managed.session_id}'",
|
|
60
83
|
hint="Use message_workers to communicate with the existing session",
|
|
61
84
|
existing_session=managed.to_dict(),
|
|
62
85
|
)
|
|
63
86
|
|
|
64
|
-
# Find the
|
|
87
|
+
# Find the terminal session by ID
|
|
65
88
|
target_session = None
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
89
|
+
try:
|
|
90
|
+
terminal_sessions = await backend.list_sessions()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.warning(f"Error listing sessions for backend {backend_id}: {e}")
|
|
93
|
+
terminal_sessions = []
|
|
94
|
+
|
|
95
|
+
for session in terminal_sessions:
|
|
96
|
+
if session.native_id == target_id:
|
|
97
|
+
target_session = session
|
|
75
98
|
break
|
|
76
99
|
|
|
77
100
|
if not target_session:
|
|
78
101
|
return error_response(
|
|
79
|
-
f"
|
|
80
|
-
hint="Run discover_workers to scan for active Claude
|
|
102
|
+
f"Terminal session not found: {target_id}",
|
|
103
|
+
hint="Run discover_workers to scan for active Claude or Codex sessions",
|
|
81
104
|
)
|
|
82
105
|
|
|
83
|
-
# Use marker-based discovery to recover original session identity
|
|
84
|
-
# This only works for sessions we originally spawned (which have our markers)
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
# Use marker-based discovery to recover original session identity.
|
|
107
|
+
# This only works for sessions we originally spawned (which have our markers).
|
|
108
|
+
agent_type = "claude"
|
|
109
|
+
match = None
|
|
110
|
+
if backend_id == "iterm":
|
|
111
|
+
match = find_jsonl_by_iterm_id(target_id, max_age_seconds=max_age)
|
|
112
|
+
if not match:
|
|
113
|
+
codex_match = find_codex_session_by_iterm_id(
|
|
114
|
+
target_id,
|
|
115
|
+
max_age_seconds=max_age,
|
|
116
|
+
)
|
|
117
|
+
if not codex_match:
|
|
118
|
+
return error_response(
|
|
119
|
+
"Session not found or not spawned by claude-team",
|
|
120
|
+
hint="adopt_worker only works for sessions originally spawned by claude-team. "
|
|
121
|
+
"External sessions cannot be reliably correlated to their JSONL files.",
|
|
122
|
+
iterm_session_id=target_id,
|
|
123
|
+
)
|
|
124
|
+
match = codex_match
|
|
125
|
+
agent_type = "codex"
|
|
126
|
+
elif backend_id == "tmux":
|
|
127
|
+
match = find_jsonl_by_tmux_id(target_id, max_age_seconds=max_age)
|
|
128
|
+
if not match:
|
|
129
|
+
codex_match = find_codex_session_by_tmux_id(
|
|
130
|
+
target_id,
|
|
131
|
+
max_age_seconds=max_age,
|
|
132
|
+
)
|
|
133
|
+
if not codex_match:
|
|
134
|
+
return error_response(
|
|
135
|
+
"Session not found or not spawned by claude-team",
|
|
136
|
+
hint="adopt_worker only works for sessions originally spawned by claude-team. "
|
|
137
|
+
"External sessions cannot be reliably correlated to their JSONL files.",
|
|
138
|
+
tmux_pane_id=target_id,
|
|
139
|
+
)
|
|
140
|
+
match = codex_match
|
|
141
|
+
agent_type = "codex"
|
|
142
|
+
else:
|
|
87
143
|
return error_response(
|
|
88
|
-
"
|
|
89
|
-
hint="
|
|
90
|
-
"External sessions cannot be reliably correlated to their JSONL files.",
|
|
91
|
-
iterm_session_id=iterm_session_id,
|
|
144
|
+
"adopt_worker is only supported with iTerm2 or tmux backend",
|
|
145
|
+
hint=HINTS["terminal_backend_required"],
|
|
92
146
|
)
|
|
93
147
|
|
|
94
148
|
logger.info(
|
|
95
|
-
|
|
96
|
-
f"project={match.project_path}, internal_id={match.internal_session_id}"
|
|
149
|
+
"Recovered session via terminal marker: "
|
|
150
|
+
f"project={match.project_path}, internal_id={match.internal_session_id}, "
|
|
151
|
+
f"agent_type={agent_type}"
|
|
97
152
|
)
|
|
98
153
|
|
|
99
154
|
# Validate project path still exists
|
|
@@ -105,12 +160,14 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
105
160
|
|
|
106
161
|
# Register with recovered identity (no new marker needed)
|
|
107
162
|
managed = registry.add(
|
|
108
|
-
|
|
163
|
+
terminal_session=target_session,
|
|
109
164
|
project_path=match.project_path,
|
|
110
165
|
name=session_name,
|
|
111
166
|
session_id=match.internal_session_id, # Recover original ID
|
|
112
167
|
)
|
|
113
|
-
managed.
|
|
168
|
+
managed.agent_type = agent_type
|
|
169
|
+
if agent_type == "claude":
|
|
170
|
+
managed.claude_session_id = match.jsonl_path.stem
|
|
114
171
|
|
|
115
172
|
# Mark ready immediately (no discovery needed, we already have it)
|
|
116
173
|
registry.update_status(managed.session_id, SessionStatus.READY)
|
|
@@ -14,7 +14,7 @@ from mcp.server.session import ServerSession
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
15
15
|
from ..server import AppContext
|
|
16
16
|
|
|
17
|
-
from ..iterm_utils import
|
|
17
|
+
from ..iterm_utils import CODEX_PRE_ENTER_DELAY
|
|
18
18
|
from ..registry import SessionRegistry, SessionStatus
|
|
19
19
|
from ..worktree import WorktreeError, remove_worktree
|
|
20
20
|
from ..utils import error_response, HINTS
|
|
@@ -22,7 +22,28 @@ from ..utils import error_response, HINTS
|
|
|
22
22
|
logger = logging.getLogger("claude-team-mcp")
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def _compute_prompt_delay(text: str, agent_type: str) -> float:
|
|
26
|
+
"""Compute a safe delay before sending Enter for a prompt."""
|
|
27
|
+
line_count = text.count("\n")
|
|
28
|
+
char_count = len(text)
|
|
29
|
+
if line_count > 0:
|
|
30
|
+
paste_delay = min(2.0, 0.1 + (line_count * 0.01) + (char_count / 1000 * 0.05))
|
|
31
|
+
else:
|
|
32
|
+
paste_delay = 0.05
|
|
33
|
+
if agent_type == "codex":
|
|
34
|
+
return max(CODEX_PRE_ENTER_DELAY, paste_delay)
|
|
35
|
+
return paste_delay
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _send_prompt(backend, session, text: str, agent_type: str) -> None:
|
|
39
|
+
"""Send a prompt and press Enter using the active terminal backend."""
|
|
40
|
+
await backend.send_text(session, text)
|
|
41
|
+
await asyncio.sleep(_compute_prompt_delay(text, agent_type))
|
|
42
|
+
await backend.send_key(session, "enter")
|
|
43
|
+
|
|
44
|
+
|
|
25
45
|
async def _close_single_worker(
|
|
46
|
+
backend,
|
|
26
47
|
session,
|
|
27
48
|
session_id: str,
|
|
28
49
|
registry: "SessionRegistry",
|
|
@@ -38,6 +59,7 @@ async def _close_single_worker(
|
|
|
38
59
|
session: The ManagedSession object
|
|
39
60
|
session_id: ID of the session to close
|
|
40
61
|
registry: The session registry
|
|
62
|
+
backend: Terminal backend used for terminal operations
|
|
41
63
|
force: If True, force close even if session is busy
|
|
42
64
|
|
|
43
65
|
Returns:
|
|
@@ -54,14 +76,20 @@ async def _close_single_worker(
|
|
|
54
76
|
|
|
55
77
|
try:
|
|
56
78
|
# Send Ctrl+C to interrupt any running operation
|
|
57
|
-
await send_key(session.
|
|
79
|
+
await backend.send_key(session.terminal_session, "ctrl-c")
|
|
58
80
|
# TODO(rabsef-bicrym): Programmatically time these actions
|
|
59
81
|
await asyncio.sleep(1.0)
|
|
60
82
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
83
|
+
if session.agent_type == "codex":
|
|
84
|
+
# Codex exits via Ctrl+C (may require a second press).
|
|
85
|
+
await backend.send_key(session.terminal_session, "ctrl-c")
|
|
86
|
+
# TODO(rabsef-bicrym): Programmatically time these actions
|
|
87
|
+
await asyncio.sleep(1.0)
|
|
88
|
+
else:
|
|
89
|
+
# Claude exits via /exit.
|
|
90
|
+
await _send_prompt(backend, session.terminal_session, "/exit", session.agent_type)
|
|
91
|
+
# TODO(rabsef-bicrym): Programmatically time these actions
|
|
92
|
+
await asyncio.sleep(1.0)
|
|
65
93
|
|
|
66
94
|
# Clean up worktree if exists (keeps branch alive for cherry-picking)
|
|
67
95
|
worktree_cleaned = False
|
|
@@ -76,8 +104,8 @@ async def _close_single_worker(
|
|
|
76
104
|
# Log but don't fail the close
|
|
77
105
|
logger.warning(f"Failed to clean up worktree for {session_id}: {e}")
|
|
78
106
|
|
|
79
|
-
# Close the
|
|
80
|
-
await
|
|
107
|
+
# Close the terminal pane/window
|
|
108
|
+
await backend.close_session(session.terminal_session, force=force)
|
|
81
109
|
|
|
82
110
|
# Remove from registry
|
|
83
111
|
registry.remove(session_id)
|
|
@@ -111,7 +139,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
111
139
|
Close one or more managed Claude Code sessions.
|
|
112
140
|
|
|
113
141
|
Gracefully terminates the Claude sessions in parallel and closes
|
|
114
|
-
their
|
|
142
|
+
their terminal panes/windows. All session_ids must exist in the registry.
|
|
115
143
|
|
|
116
144
|
⚠️ **NOTE: WORKTREE CLEANUP**
|
|
117
145
|
Workers with worktrees commit to ephemeral branches. When closed:
|
|
@@ -137,6 +165,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
137
165
|
"""
|
|
138
166
|
app_ctx = ctx.request_context.lifespan_context
|
|
139
167
|
registry = app_ctx.registry
|
|
168
|
+
backend = app_ctx.terminal_backend
|
|
140
169
|
|
|
141
170
|
if not session_ids:
|
|
142
171
|
return error_response(
|
|
@@ -166,7 +195,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
166
195
|
|
|
167
196
|
# Close all sessions in parallel
|
|
168
197
|
async def close_one(sid: str, session) -> tuple[str, dict]:
|
|
169
|
-
result = await _close_single_worker(session, sid, registry, force)
|
|
198
|
+
result = await _close_single_worker(backend, session, sid, registry, force)
|
|
170
199
|
return (sid, result)
|
|
171
200
|
|
|
172
201
|
tasks = [close_one(sid, session) for sid, session in sessions_to_close]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Discover workers tool.
|
|
3
3
|
|
|
4
|
-
Provides discover_workers for finding existing Claude Code
|
|
4
|
+
Provides discover_workers for finding existing Claude Code and Codex sessions.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
@@ -14,10 +14,15 @@ if TYPE_CHECKING:
|
|
|
14
14
|
from ..server import AppContext
|
|
15
15
|
|
|
16
16
|
from ..session_state import (
|
|
17
|
+
find_codex_session_by_iterm_id,
|
|
18
|
+
find_codex_session_by_tmux_id,
|
|
17
19
|
find_jsonl_by_iterm_id,
|
|
20
|
+
find_jsonl_by_tmux_id,
|
|
18
21
|
get_project_dir,
|
|
22
|
+
parse_codex_session,
|
|
19
23
|
parse_session,
|
|
20
24
|
)
|
|
25
|
+
from ..utils import error_response, HINTS
|
|
21
26
|
|
|
22
27
|
logger = logging.getLogger("claude-team-mcp")
|
|
23
28
|
|
|
@@ -31,12 +36,14 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
31
36
|
max_age: int = 3600,
|
|
32
37
|
) -> dict:
|
|
33
38
|
"""
|
|
34
|
-
Discover existing Claude Code sessions running in
|
|
39
|
+
Discover existing Claude Code and Codex sessions running in the active terminal backend.
|
|
35
40
|
|
|
36
|
-
For each
|
|
37
|
-
matching
|
|
38
|
-
their
|
|
39
|
-
|
|
41
|
+
For each terminal session, searches JSONL files in ~/.claude/projects/ and
|
|
42
|
+
~/.codex/sessions/ for matching terminal session markers. Sessions spawned
|
|
43
|
+
by claude-team write their terminal IDs into the JSONL
|
|
44
|
+
(e.g., <!claude-team-iterm:UUID!> or <!claude-team-tmux:%1!>), enabling
|
|
45
|
+
reliable detection and recovery after MCP server restarts.
|
|
46
|
+
For tmux, only panes in claude-team-managed sessions are scanned.
|
|
40
47
|
|
|
41
48
|
Only JSONL files modified within max_age seconds are checked. If a session
|
|
42
49
|
was started more than max_age seconds ago and hasn't had recent activity,
|
|
@@ -49,44 +56,57 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
49
56
|
Returns:
|
|
50
57
|
Dict with:
|
|
51
58
|
- sessions: List of discovered sessions, each containing:
|
|
52
|
-
-
|
|
59
|
+
- backend_id: Terminal backend identifier
|
|
60
|
+
- iterm_session_id: iTerm2's internal session ID (iTerm backend)
|
|
61
|
+
- tmux_pane_id: tmux pane id (tmux backend)
|
|
53
62
|
- project_path: Detected project path
|
|
54
|
-
- claude_session_id: The JSONL session UUID
|
|
63
|
+
- claude_session_id: The JSONL session UUID (Claude only)
|
|
64
|
+
- codex_session_id: The JSONL session UUID (Codex only)
|
|
55
65
|
- internal_session_id: Our short session ID (e.g., "b48e2d5b")
|
|
56
66
|
- last_assistant_preview: Preview of last assistant message
|
|
57
67
|
- already_managed: True if already in our registry
|
|
58
|
-
|
|
68
|
+
- agent_type: "claude" or "codex"
|
|
69
|
+
- count: Total number of sessions found
|
|
59
70
|
- unmanaged_count: Number not yet in registry (available to adopt)
|
|
60
71
|
"""
|
|
61
72
|
app_ctx = ctx.request_context.lifespan_context
|
|
62
73
|
registry = app_ctx.registry
|
|
63
74
|
|
|
64
|
-
# Ensure we have a fresh connection
|
|
65
|
-
|
|
75
|
+
# Ensure we have a fresh backend connection/state
|
|
76
|
+
backend = await ensure_connection(app_ctx)
|
|
77
|
+
backend_id = backend.backend_id
|
|
78
|
+
|
|
79
|
+
if backend_id not in ("iterm", "tmux"):
|
|
80
|
+
return error_response(
|
|
81
|
+
"discover_workers is only supported with iTerm2 or tmux backend",
|
|
82
|
+
hint=HINTS["terminal_backend_required"],
|
|
83
|
+
)
|
|
66
84
|
|
|
67
85
|
discovered = []
|
|
68
86
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
managed_ids = {
|
|
88
|
+
s.terminal_session.native_id
|
|
89
|
+
for s in registry.list_all()
|
|
90
|
+
if s.terminal_session.backend_id == backend_id
|
|
72
91
|
}
|
|
73
92
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Look for this iTerm session ID in recent JSONL files
|
|
80
|
-
# Claude-team spawned sessions write their iTerm ID as a marker
|
|
81
|
-
match = find_jsonl_by_iterm_id(
|
|
82
|
-
iterm_session.session_id,
|
|
83
|
-
max_age_seconds=max_age
|
|
84
|
-
)
|
|
93
|
+
try:
|
|
94
|
+
terminal_sessions = await backend.list_sessions()
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logger.warning(f"Error listing sessions for backend {backend_id}: {e}")
|
|
97
|
+
terminal_sessions = []
|
|
85
98
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
99
|
+
for terminal_session in terminal_sessions:
|
|
100
|
+
native_id = terminal_session.native_id
|
|
101
|
+
try:
|
|
102
|
+
if backend_id == "iterm":
|
|
103
|
+
# Look for this iTerm session ID in recent JSONL files
|
|
104
|
+
match = find_jsonl_by_iterm_id(
|
|
105
|
+
native_id,
|
|
106
|
+
max_age_seconds=max_age,
|
|
107
|
+
)
|
|
89
108
|
|
|
109
|
+
if match:
|
|
90
110
|
project_path = match.project_path
|
|
91
111
|
claude_session_id = match.jsonl_path.stem
|
|
92
112
|
internal_session_id = match.internal_session_id
|
|
@@ -94,7 +114,10 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
94
114
|
# Get last assistant message preview from JSONL
|
|
95
115
|
last_assistant_preview = None
|
|
96
116
|
try:
|
|
97
|
-
jsonl_path =
|
|
117
|
+
jsonl_path = (
|
|
118
|
+
get_project_dir(project_path)
|
|
119
|
+
/ f"{claude_session_id}.jsonl"
|
|
120
|
+
)
|
|
98
121
|
if jsonl_path.exists():
|
|
99
122
|
state = parse_session(jsonl_path)
|
|
100
123
|
if state.last_assistant_message:
|
|
@@ -108,18 +131,139 @@ def register_tools(mcp: FastMCP, ensure_connection) -> None:
|
|
|
108
131
|
logger.debug(f"Could not get conversation preview: {e}")
|
|
109
132
|
|
|
110
133
|
discovered.append({
|
|
111
|
-
"
|
|
134
|
+
"backend_id": backend_id,
|
|
135
|
+
"iterm_session_id": native_id,
|
|
112
136
|
"project_path": project_path,
|
|
113
137
|
"claude_session_id": claude_session_id,
|
|
114
138
|
"internal_session_id": internal_session_id,
|
|
115
139
|
"last_assistant_preview": last_assistant_preview,
|
|
116
|
-
"already_managed":
|
|
140
|
+
"already_managed": native_id in managed_ids,
|
|
141
|
+
"agent_type": "claude",
|
|
117
142
|
})
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Fall back to Codex marker scan if no Claude match
|
|
146
|
+
codex_match = find_codex_session_by_iterm_id(
|
|
147
|
+
native_id,
|
|
148
|
+
max_age_seconds=max_age,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if not codex_match:
|
|
152
|
+
continue
|
|
118
153
|
|
|
154
|
+
project_path = codex_match.project_path
|
|
155
|
+
internal_session_id = codex_match.internal_session_id
|
|
156
|
+
|
|
157
|
+
# Get last assistant message preview from Codex JSONL
|
|
158
|
+
last_assistant_preview = None
|
|
159
|
+
try:
|
|
160
|
+
jsonl_path = codex_match.jsonl_path
|
|
161
|
+
if jsonl_path.exists():
|
|
162
|
+
state = parse_codex_session(jsonl_path)
|
|
163
|
+
if state.last_assistant_message:
|
|
164
|
+
content = state.last_assistant_message.content
|
|
165
|
+
last_assistant_preview = (
|
|
166
|
+
content[:200] + "..."
|
|
167
|
+
if len(content) > 200
|
|
168
|
+
else content
|
|
169
|
+
)
|
|
119
170
|
except Exception as e:
|
|
120
|
-
logger.
|
|
171
|
+
logger.debug(f"Could not get conversation preview: {e}")
|
|
172
|
+
|
|
173
|
+
discovered.append({
|
|
174
|
+
"backend_id": backend_id,
|
|
175
|
+
"iterm_session_id": native_id,
|
|
176
|
+
"project_path": project_path,
|
|
177
|
+
"codex_session_id": codex_match.jsonl_path.stem,
|
|
178
|
+
"internal_session_id": internal_session_id,
|
|
179
|
+
"last_assistant_preview": last_assistant_preview,
|
|
180
|
+
"already_managed": native_id in managed_ids,
|
|
181
|
+
"agent_type": "codex",
|
|
182
|
+
})
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
# Tmux backend
|
|
186
|
+
match = find_jsonl_by_tmux_id(
|
|
187
|
+
native_id,
|
|
188
|
+
max_age_seconds=max_age,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if not match:
|
|
192
|
+
# Fall back to Codex marker scan if no Claude match
|
|
193
|
+
codex_match = find_codex_session_by_tmux_id(
|
|
194
|
+
native_id,
|
|
195
|
+
max_age_seconds=max_age,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if not codex_match:
|
|
121
199
|
continue
|
|
122
200
|
|
|
201
|
+
project_path = codex_match.project_path
|
|
202
|
+
internal_session_id = codex_match.internal_session_id
|
|
203
|
+
|
|
204
|
+
# Get last assistant message preview from Codex JSONL
|
|
205
|
+
last_assistant_preview = None
|
|
206
|
+
try:
|
|
207
|
+
jsonl_path = codex_match.jsonl_path
|
|
208
|
+
if jsonl_path.exists():
|
|
209
|
+
state = parse_codex_session(jsonl_path)
|
|
210
|
+
if state.last_assistant_message:
|
|
211
|
+
content = state.last_assistant_message.content
|
|
212
|
+
last_assistant_preview = (
|
|
213
|
+
content[:200] + "..."
|
|
214
|
+
if len(content) > 200
|
|
215
|
+
else content
|
|
216
|
+
)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.debug(f"Could not get conversation preview: {e}")
|
|
219
|
+
|
|
220
|
+
discovered.append({
|
|
221
|
+
"backend_id": backend_id,
|
|
222
|
+
"tmux_pane_id": native_id,
|
|
223
|
+
"project_path": project_path,
|
|
224
|
+
"codex_session_id": codex_match.jsonl_path.stem,
|
|
225
|
+
"internal_session_id": internal_session_id,
|
|
226
|
+
"last_assistant_preview": last_assistant_preview,
|
|
227
|
+
"already_managed": native_id in managed_ids,
|
|
228
|
+
"agent_type": "codex",
|
|
229
|
+
})
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
project_path = match.project_path
|
|
233
|
+
claude_session_id = match.jsonl_path.stem
|
|
234
|
+
internal_session_id = match.internal_session_id
|
|
235
|
+
|
|
236
|
+
# Get last assistant message preview from JSONL
|
|
237
|
+
last_assistant_preview = None
|
|
238
|
+
try:
|
|
239
|
+
jsonl_path = get_project_dir(project_path) / f"{claude_session_id}.jsonl"
|
|
240
|
+
if jsonl_path.exists():
|
|
241
|
+
state = parse_session(jsonl_path)
|
|
242
|
+
if state.last_assistant_message:
|
|
243
|
+
content = state.last_assistant_message.content
|
|
244
|
+
last_assistant_preview = (
|
|
245
|
+
content[:200] + "..."
|
|
246
|
+
if len(content) > 200
|
|
247
|
+
else content
|
|
248
|
+
)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.debug(f"Could not get conversation preview: {e}")
|
|
251
|
+
|
|
252
|
+
discovered.append({
|
|
253
|
+
"backend_id": backend_id,
|
|
254
|
+
"tmux_pane_id": native_id,
|
|
255
|
+
"project_path": project_path,
|
|
256
|
+
"claude_session_id": claude_session_id,
|
|
257
|
+
"internal_session_id": internal_session_id,
|
|
258
|
+
"last_assistant_preview": last_assistant_preview,
|
|
259
|
+
"already_managed": native_id in managed_ids,
|
|
260
|
+
"agent_type": "claude",
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.warning(f"Error scanning session {native_id}: {e}")
|
|
265
|
+
continue
|
|
266
|
+
|
|
123
267
|
unmanaged = [s for s in discovered if not s["already_managed"]]
|
|
124
268
|
|
|
125
269
|
return {
|
|
@@ -4,6 +4,7 @@ List workers tool.
|
|
|
4
4
|
Provides list_workers for viewing all managed Claude Code sessions.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import TYPE_CHECKING
|
|
8
9
|
|
|
9
10
|
from mcp.server.fastmcp import Context, FastMCP
|
|
@@ -23,6 +24,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
23
24
|
async def list_workers(
|
|
24
25
|
ctx: Context[ServerSession, "AppContext"],
|
|
25
26
|
status_filter: str | None = None,
|
|
27
|
+
project_filter: str | None = None,
|
|
26
28
|
) -> dict:
|
|
27
29
|
"""
|
|
28
30
|
List all managed Claude Code sessions.
|
|
@@ -32,6 +34,7 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
32
34
|
|
|
33
35
|
Args:
|
|
34
36
|
status_filter: Optional filter by status - "ready", "busy", "spawning", "closed"
|
|
37
|
+
project_filter: Optional filter by project path (full path, basename, or partial match)
|
|
35
38
|
|
|
36
39
|
Returns:
|
|
37
40
|
Dict with:
|
|
@@ -55,6 +58,32 @@ def register_tools(mcp: FastMCP) -> None:
|
|
|
55
58
|
else:
|
|
56
59
|
sessions = registry.list_all()
|
|
57
60
|
|
|
61
|
+
# Filter by project path or main repo path
|
|
62
|
+
if project_filter:
|
|
63
|
+
normalized_filter = project_filter.strip()
|
|
64
|
+
if normalized_filter:
|
|
65
|
+
filtered_sessions = []
|
|
66
|
+
for session in sessions:
|
|
67
|
+
candidates = [session.project_path]
|
|
68
|
+
if session.main_repo_path is not None:
|
|
69
|
+
candidates.append(str(session.main_repo_path))
|
|
70
|
+
matches = False
|
|
71
|
+
for candidate in candidates:
|
|
72
|
+
if not candidate:
|
|
73
|
+
continue
|
|
74
|
+
if candidate == normalized_filter:
|
|
75
|
+
matches = True
|
|
76
|
+
break
|
|
77
|
+
if Path(candidate).name == normalized_filter:
|
|
78
|
+
matches = True
|
|
79
|
+
break
|
|
80
|
+
if normalized_filter in candidate:
|
|
81
|
+
matches = True
|
|
82
|
+
break
|
|
83
|
+
if matches:
|
|
84
|
+
filtered_sessions.append(session)
|
|
85
|
+
sessions = filtered_sessions
|
|
86
|
+
|
|
58
87
|
# Sort by created_at
|
|
59
88
|
sessions = sorted(sessions, key=lambda s: s.created_at)
|
|
60
89
|
|