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.
Files changed (35) hide show
  1. claude_team/__init__.py +11 -0
  2. claude_team/events.py +501 -0
  3. claude_team/idle_detection.py +173 -0
  4. claude_team/poller.py +245 -0
  5. claude_team_mcp/cli_backends/__init__.py +4 -2
  6. claude_team_mcp/cli_backends/claude.py +45 -5
  7. claude_team_mcp/cli_backends/codex.py +44 -3
  8. claude_team_mcp/config.py +350 -0
  9. claude_team_mcp/config_cli.py +263 -0
  10. claude_team_mcp/idle_detection.py +16 -3
  11. claude_team_mcp/issue_tracker/__init__.py +68 -3
  12. claude_team_mcp/iterm_utils.py +5 -73
  13. claude_team_mcp/registry.py +43 -26
  14. claude_team_mcp/server.py +164 -61
  15. claude_team_mcp/session_state.py +364 -2
  16. claude_team_mcp/terminal_backends/__init__.py +49 -0
  17. claude_team_mcp/terminal_backends/base.py +106 -0
  18. claude_team_mcp/terminal_backends/iterm.py +251 -0
  19. claude_team_mcp/terminal_backends/tmux.py +683 -0
  20. claude_team_mcp/tools/__init__.py +4 -2
  21. claude_team_mcp/tools/adopt_worker.py +89 -32
  22. claude_team_mcp/tools/close_workers.py +39 -10
  23. claude_team_mcp/tools/discover_workers.py +176 -32
  24. claude_team_mcp/tools/list_workers.py +29 -0
  25. claude_team_mcp/tools/message_workers.py +35 -5
  26. claude_team_mcp/tools/poll_worker_changes.py +227 -0
  27. claude_team_mcp/tools/spawn_workers.py +254 -153
  28. claude_team_mcp/tools/wait_idle_workers.py +1 -0
  29. claude_team_mcp/utils/errors.py +7 -3
  30. claude_team_mcp/worktree.py +73 -12
  31. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/METADATA +1 -1
  32. claude_team_mcp-0.8.0.dist-info/RECORD +54 -0
  33. claude_team_mcp-0.6.1.dist-info/RECORD +0 -43
  34. {claude_team_mcp-0.6.1.dist-info → claude_team_mcp-0.8.0.dist-info}/WHEEL +0 -0
  35. {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 iTerm2 connection is alive
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 iTerm2 operations
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 iTerm2 Claude Code sessions.
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 find_jsonl_by_iterm_id
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 iTerm2 Claude Code session into the MCP registry.
41
+ Adopt an existing terminal Claude Code or Codex session into the MCP registry.
36
42
 
37
- Takes an iTerm2 session ID (from discover_workers) and registers it
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 (websocket can go stale)
53
- _, app = await ensure_connection(app_ctx)
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 managed.iterm_session.session_id == iterm_session_id:
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 iTerm2 session by ID
87
+ # Find the terminal session by ID
65
88
  target_session = None
66
- for window in app.terminal_windows:
67
- for tab in window.tabs:
68
- for iterm_session in tab.sessions:
69
- if iterm_session.session_id == iterm_session_id:
70
- target_session = iterm_session
71
- break
72
- if target_session:
73
- break
74
- if target_session:
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"iTerm2 session not found: {iterm_session_id}",
80
- hint="Run discover_workers to scan for active Claude sessions in iTerm2",
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
- match = find_jsonl_by_iterm_id(iterm_session_id, max_age_seconds=max_age)
86
- if not match:
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
- "Session not found or not spawned by claude-team",
89
- hint="adopt_worker only works for sessions originally spawned by claude-team. "
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
- f"Recovered session via iTerm marker: "
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
- iterm_session=target_session,
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.claude_session_id = match.jsonl_path.stem
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 send_prompt, send_key, close_pane
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.iterm_session, "ctrl-c")
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
- # Send /exit to quit Claude
62
- await send_prompt(session.iterm_session, "/exit", submit=True)
63
- # TODO(rabsef-bicrym): Programmatically time these actions
64
- await asyncio.sleep(1.0)
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 iTerm2 pane/window
80
- await close_pane(session.iterm_session, force=force)
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 iTerm2 panes. All session_ids must exist in the registry.
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 sessions in iTerm2.
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 iTerm2.
39
+ Discover existing Claude Code and Codex sessions running in the active terminal backend.
35
40
 
36
- For each iTerm2 pane, searches JSONL files in ~/.claude/projects/ for a
37
- matching iTerm session ID marker. Sessions spawned by claude-team write
38
- their iTerm session ID into the JSONL (e.g., <!claude-team-iterm:UUID!>),
39
- enabling reliable detection and recovery after MCP server restarts.
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
- - iterm_session_id: iTerm2's internal session ID
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
- - count: Total number of Claude sessions found
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 (websocket can go stale)
65
- _, app = await ensure_connection(app_ctx)
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
- # Get all managed iTerm session IDs so we can flag already-managed ones
70
- managed_iterm_ids = {
71
- s.iterm_session.session_id for s in registry.list_all()
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
- # Scan all iTerm2 panes and check if their session ID appears in any JSONL
75
- for window in app.terminal_windows:
76
- for tab in window.tabs:
77
- for iterm_session in tab.sessions:
78
- try:
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
- if not match:
87
- # Not a Claude session we know about
88
- continue
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 = get_project_dir(project_path) / f"{claude_session_id}.jsonl"
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
- "iterm_session_id": iterm_session.session_id,
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": iterm_session.session_id in managed_iterm_ids,
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.warning(f"Error scanning session {iterm_session.session_id}: {e}")
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