mcpforunityserver 9.3.0b20260128055651__py3-none-any.whl → 9.3.0b20260129121506__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 (61) hide show
  1. cli/commands/animation.py +6 -9
  2. cli/commands/asset.py +50 -80
  3. cli/commands/audio.py +14 -22
  4. cli/commands/batch.py +20 -33
  5. cli/commands/code.py +63 -70
  6. cli/commands/component.py +33 -55
  7. cli/commands/editor.py +122 -188
  8. cli/commands/gameobject.py +60 -83
  9. cli/commands/instance.py +28 -36
  10. cli/commands/lighting.py +54 -59
  11. cli/commands/material.py +39 -68
  12. cli/commands/prefab.py +63 -81
  13. cli/commands/scene.py +30 -54
  14. cli/commands/script.py +32 -50
  15. cli/commands/shader.py +43 -55
  16. cli/commands/texture.py +53 -51
  17. cli/commands/tool.py +24 -27
  18. cli/commands/ui.py +125 -130
  19. cli/commands/vfx.py +84 -138
  20. cli/utils/confirmation.py +37 -0
  21. cli/utils/connection.py +32 -2
  22. cli/utils/constants.py +23 -0
  23. cli/utils/parsers.py +112 -0
  24. core/config.py +0 -4
  25. core/telemetry.py +20 -2
  26. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/METADATA +21 -1
  27. mcpforunityserver-9.3.0b20260129121506.dist-info/RECORD +103 -0
  28. services/resources/active_tool.py +1 -1
  29. services/resources/custom_tools.py +1 -1
  30. services/resources/editor_state.py +1 -1
  31. services/resources/gameobject.py +4 -4
  32. services/resources/layers.py +1 -1
  33. services/resources/menu_items.py +1 -1
  34. services/resources/prefab.py +3 -3
  35. services/resources/prefab_stage.py +1 -1
  36. services/resources/project_info.py +1 -1
  37. services/resources/selection.py +1 -1
  38. services/resources/tags.py +1 -1
  39. services/resources/tests.py +40 -8
  40. services/resources/unity_instances.py +1 -1
  41. services/resources/windows.py +1 -1
  42. services/tools/__init__.py +3 -1
  43. services/tools/find_gameobjects.py +32 -11
  44. services/tools/manage_gameobject.py +11 -66
  45. services/tools/manage_material.py +4 -37
  46. services/tools/manage_prefabs.py +51 -7
  47. services/tools/manage_script.py +1 -1
  48. services/tools/manage_texture.py +10 -96
  49. services/tools/run_tests.py +67 -4
  50. services/tools/utils.py +217 -0
  51. transport/models.py +1 -0
  52. transport/plugin_hub.py +2 -1
  53. transport/plugin_registry.py +3 -0
  54. transport/unity_transport.py +0 -51
  55. utils/focus_nudge.py +291 -23
  56. mcpforunityserver-9.3.0b20260128055651.dist-info/RECORD +0 -101
  57. utils/reload_sentinel.py +0 -9
  58. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/WHEEL +0 -0
  59. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/entry_points.txt +0 -0
  60. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/licenses/LICENSE +0 -0
  61. {mcpforunityserver-9.3.0b20260128055651.dist-info → mcpforunityserver-9.3.0b20260129121506.dist-info}/top_level.txt +0 -0
transport/models.py CHANGED
@@ -31,6 +31,7 @@ class RegisterMessage(BaseModel):
31
31
  project_name: str = "Unknown Project"
32
32
  project_hash: str
33
33
  unity_version: str = "Unknown"
34
+ project_path: str | None = None # Full path to project root (for focus nudging)
34
35
 
35
36
 
36
37
  class RegisterToolsMessage(BaseModel):
transport/plugin_hub.py CHANGED
@@ -279,6 +279,7 @@ class PluginHub(WebSocketEndpoint):
279
279
  project_name = payload.project_name
280
280
  project_hash = payload.project_hash
281
281
  unity_version = payload.unity_version
282
+ project_path = payload.project_path
282
283
 
283
284
  if not project_hash:
284
285
  await websocket.close(code=4400)
@@ -290,7 +291,7 @@ class PluginHub(WebSocketEndpoint):
290
291
  response = RegisteredMessage(session_id=session_id)
291
292
  await websocket.send_json(response.model_dump())
292
293
 
293
- session = await registry.register(session_id, project_name, project_hash, unity_version)
294
+ session = await registry.register(session_id, project_name, project_hash, unity_version, project_path)
294
295
  async with lock:
295
296
  cls._connections[session.session_id] = websocket
296
297
  logger.info(f"Plugin registered: {project_name} ({project_hash})")
@@ -22,6 +22,7 @@ class PluginSession:
22
22
  connected_at: datetime
23
23
  tools: dict[str, ToolDefinitionModel] = field(default_factory=dict)
24
24
  project_id: str | None = None
25
+ project_path: str | None = None # Full path to project root (for focus nudging)
25
26
 
26
27
 
27
28
  class PluginRegistry:
@@ -43,6 +44,7 @@ class PluginRegistry:
43
44
  project_name: str,
44
45
  project_hash: str,
45
46
  unity_version: str,
47
+ project_path: str | None = None,
46
48
  ) -> PluginSession:
47
49
  """Register (or replace) a plugin session.
48
50
 
@@ -60,6 +62,7 @@ class PluginRegistry:
60
62
  unity_version=unity_version,
61
63
  registered_at=now,
62
64
  connected_at=now,
65
+ project_path=project_path,
63
66
  )
64
67
 
65
68
  # Remove old mapping for this hash if it existed under a different session
@@ -25,57 +25,6 @@ def _current_transport() -> str:
25
25
  return "http" if _is_http_transport() else "stdio"
26
26
 
27
27
 
28
- def with_unity_instance(
29
- log: str | Callable[[Context, tuple, dict, str | None], str] | None = None,
30
- *,
31
- kwarg_name: str = "unity_instance",
32
- ):
33
- def _decorate(fn: Callable[..., T]):
34
- is_coro = asyncio.iscoroutinefunction(fn)
35
-
36
- def _compose_message(ctx: Context, a: tuple, k: dict, inst: str | None) -> str | None:
37
- if log is None:
38
- return None
39
- if callable(log):
40
- try:
41
- return log(ctx, a, k, inst)
42
- except Exception:
43
- return None
44
- try:
45
- return str(log).format(unity_instance=inst or "default")
46
- except Exception:
47
- return str(log)
48
-
49
- if is_coro:
50
- async def _wrapper(ctx: Context, *args, **kwargs):
51
- inst = get_unity_instance_from_context(ctx)
52
- msg = _compose_message(ctx, args, kwargs, inst)
53
- if msg:
54
- try:
55
- await ctx.info(msg)
56
- except Exception:
57
- pass
58
- kwargs.setdefault(kwarg_name, inst)
59
- return await fn(ctx, *args, **kwargs)
60
- else:
61
- async def _wrapper(ctx: Context, *args, **kwargs):
62
- inst = get_unity_instance_from_context(ctx)
63
- msg = _compose_message(ctx, args, kwargs, inst)
64
- if msg:
65
- try:
66
- await ctx.info(msg)
67
- except Exception:
68
- pass
69
- kwargs.setdefault(kwarg_name, inst)
70
- return fn(ctx, *args, **kwargs)
71
-
72
- from functools import wraps
73
-
74
- return wraps(fn)(_wrapper) # type: ignore[arg-type]
75
-
76
- return _decorate
77
-
78
-
79
28
  async def send_with_unity_instance(
80
29
  send_fn: Callable[..., Awaitable[T]],
81
30
  unity_instance: str | None,
utils/focus_nudge.py CHANGED
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  import logging
13
+ import os
13
14
  import platform
14
15
  import shutil
15
16
  import subprocess
@@ -17,9 +18,38 @@ import time
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
- # Minimum seconds between nudges to avoid focus thrashing
21
- _MIN_NUDGE_INTERVAL_S = 5.0
21
+
22
+ def _parse_env_float(env_var: str, default: float) -> float:
23
+ """Safely parse environment variable as float, logging warnings on failure."""
24
+ value = os.environ.get(env_var)
25
+ if value is None:
26
+ return default
27
+ try:
28
+ parsed = float(value)
29
+ if parsed <= 0:
30
+ logger.warning(f"Invalid {env_var}={value!r}, using default {default}: must be > 0")
31
+ return default
32
+ return parsed
33
+ except (ValueError, TypeError) as e:
34
+ logger.warning(f"Invalid {env_var}={value!r}, using default {default}: {e}")
35
+ return default
36
+
37
+
38
+ # Base interval between nudges (exponentially increases with consecutive nudges)
39
+ # Can be overridden via UNITY_MCP_NUDGE_BASE_INTERVAL_S environment variable
40
+ _BASE_NUDGE_INTERVAL_S = _parse_env_float("UNITY_MCP_NUDGE_BASE_INTERVAL_S", 1.0)
41
+
42
+ # Maximum interval between nudges (cap for exponential backoff)
43
+ # Can be overridden via UNITY_MCP_NUDGE_MAX_INTERVAL_S environment variable
44
+ _MAX_NUDGE_INTERVAL_S = _parse_env_float("UNITY_MCP_NUDGE_MAX_INTERVAL_S", 10.0)
45
+
46
+ # Default duration to keep Unity focused during a nudge
47
+ # Can be overridden via UNITY_MCP_NUDGE_DURATION_S environment variable
48
+ _DEFAULT_FOCUS_DURATION_S = _parse_env_float("UNITY_MCP_NUDGE_DURATION_S", 3.0)
49
+
22
50
  _last_nudge_time: float = 0.0
51
+ _consecutive_nudges: int = 0
52
+ _last_progress_time: float = 0.0
23
53
 
24
54
 
25
55
  def _is_available() -> bool:
@@ -35,6 +65,61 @@ def _is_available() -> bool:
35
65
  return False
36
66
 
37
67
 
68
+ def _get_current_nudge_interval() -> float:
69
+ """
70
+ Calculate current nudge interval using exponential backoff.
71
+
72
+ Returns interval based on consecutive nudges without progress:
73
+ - 0 nudges: base interval (1.0s)
74
+ - 1 nudge: base * 2 (2.0s)
75
+ - 2 nudges: base * 4 (4.0s)
76
+ - 3+ nudges: base * 8 (8.0s, capped at max)
77
+ """
78
+ if _consecutive_nudges == 0:
79
+ return _BASE_NUDGE_INTERVAL_S
80
+
81
+ # Exponential backoff: interval = base * (2 ^ consecutive_nudges)
82
+ interval = _BASE_NUDGE_INTERVAL_S * (2 ** _consecutive_nudges)
83
+ return min(interval, _MAX_NUDGE_INTERVAL_S)
84
+
85
+
86
+ def _get_current_focus_duration() -> float:
87
+ """
88
+ Calculate current focus duration using exponential backoff.
89
+
90
+ Base durations (3, 5, 8, 12 seconds) are scaled proportionally by the
91
+ configured UNITY_MCP_NUDGE_DURATION_S relative to _DEFAULT_FOCUS_DURATION_S.
92
+ For example, if UNITY_MCP_NUDGE_DURATION_S=6.0 (2x default), all durations
93
+ are doubled: (6, 10, 16, 24 seconds).
94
+ """
95
+ # Base durations for each nudge level
96
+ base_durations = [3.0, 5.0, 8.0, 12.0]
97
+ base_duration = base_durations[min(_consecutive_nudges, len(base_durations) - 1)]
98
+
99
+ # Scale by ratio of configured to default duration (if UNITY_MCP_NUDGE_DURATION_S is set)
100
+ scale = 1.0
101
+ if os.environ.get("UNITY_MCP_NUDGE_DURATION_S") is not None:
102
+ configured_duration = _parse_env_float("UNITY_MCP_NUDGE_DURATION_S", _DEFAULT_FOCUS_DURATION_S)
103
+ if _DEFAULT_FOCUS_DURATION_S > 0:
104
+ scale = configured_duration / _DEFAULT_FOCUS_DURATION_S
105
+ duration = base_duration * scale
106
+ if duration <= 0:
107
+ return _DEFAULT_FOCUS_DURATION_S
108
+ return duration
109
+
110
+
111
+ def reset_nudge_backoff() -> None:
112
+ """
113
+ Reset exponential backoff when progress is detected.
114
+
115
+ Call this when test job makes progress to reset the nudge interval
116
+ back to the base interval for quick response to future stalls.
117
+ """
118
+ global _consecutive_nudges, _last_progress_time
119
+ _consecutive_nudges = 0
120
+ _last_progress_time = time.monotonic()
121
+
122
+
38
123
  def _get_frontmost_app_macos() -> str | None:
39
124
  """Get the name of the frontmost application on macOS."""
40
125
  try:
@@ -54,21 +139,161 @@ def _get_frontmost_app_macos() -> str | None:
54
139
  return None
55
140
 
56
141
 
57
- def _focus_app_macos(app_name: str) -> bool:
58
- """Focus an application by name on macOS."""
142
+ def _find_unity_pid_by_project_path(project_path: str) -> int | None:
143
+ """Find Unity Editor PID by matching project path in command line args.
144
+
145
+ Args:
146
+ project_path: Full path to Unity project root, OR just the project name.
147
+ - Full path: "/Users/name/Projects/MyGame"
148
+ - Project name: "MyGame" (will match any path ending with this)
149
+
150
+ Returns:
151
+ PID of matching Unity process, or None if not found
152
+ """
59
153
  try:
154
+ # Use ps to find Unity processes with -projectpath argument
60
155
  result = subprocess.run(
61
- ["osascript", "-e", f'tell application "{app_name}" to activate'],
156
+ ["ps", "aux"],
62
157
  capture_output=True,
63
158
  text=True,
64
159
  timeout=5,
65
160
  )
66
- return result.returncode == 0
161
+ if result.returncode != 0:
162
+ return None
163
+
164
+ # Determine if project_path is a full path or just a name
165
+ is_full_path = "/" in project_path or "\\" in project_path
166
+
167
+ # Look for Unity.app processes with matching -projectpath
168
+ for line in result.stdout.splitlines():
169
+ if "Unity.app/Contents/MacOS/Unity" not in line:
170
+ continue
171
+
172
+ # Check for -projectpath argument
173
+ if "-projectpath" not in line:
174
+ continue
175
+
176
+ if is_full_path:
177
+ # Exact match for full path
178
+ if f"-projectpath {project_path}" not in line:
179
+ continue
180
+ else:
181
+ # Match if path ends with project name (e.g., ".../UnityMCPTests")
182
+ if "-projectpath" in line:
183
+ # Extract the path after -projectpath
184
+ try:
185
+ parts = line.split("-projectpath", 1)[1].split()[0]
186
+ if not parts.endswith(f"/{project_path}") and not parts.endswith(f"\\{project_path}") and parts != project_path:
187
+ continue
188
+ except (IndexError, ValueError):
189
+ continue
190
+
191
+ # Extract PID (second column in ps aux output)
192
+ parts = line.split()
193
+ if len(parts) >= 2:
194
+ try:
195
+ pid = int(parts[1])
196
+ logger.debug(f"Found Unity PID {pid} for project path/name {project_path}")
197
+ return pid
198
+ except ValueError:
199
+ continue
200
+
201
+ logger.warning(f"No Unity process found with project path/name {project_path}")
202
+ return None
203
+ except Exception as e:
204
+ logger.debug(f"Failed to find Unity PID: {e}")
205
+ return None
206
+
207
+
208
+ def _focus_app_macos(app_name: str, unity_project_path: str | None = None) -> bool:
209
+ """Focus an application by name on macOS.
210
+
211
+ For Unity, can target a specific instance by project path (multi-instance support).
212
+
213
+ Args:
214
+ app_name: Application name to focus ("Unity" or specific app name)
215
+ unity_project_path: For Unity apps, the full project root path to match against
216
+ -projectpath command line arg (e.g., "/path/to/project" NOT "/path/to/project/Assets")
217
+ """
218
+ try:
219
+ # For Unity, use PID-based activation for precise targeting
220
+ if app_name == "Unity":
221
+ if unity_project_path:
222
+ # Find specific Unity instance by project path
223
+ pid = _find_unity_pid_by_project_path(unity_project_path)
224
+ if pid is None:
225
+ logger.warning(f"Could not find Unity PID for project {unity_project_path}, falling back to any Unity")
226
+ return _focus_any_unity_macos()
227
+
228
+ # Two-step activation for full Unity wake-up:
229
+ # 1. Bring window to front
230
+ # 2. Activate the application bundle (triggers full app activation like cmd+tab or clicking)
231
+ script = f'''
232
+ tell application "System Events"
233
+ set targetProc to first process whose unix id is {pid}
234
+ set frontmost of targetProc to true
235
+
236
+ -- Get bundle identifier to activate the app properly
237
+ set bundleID to bundle identifier of targetProc
238
+ end tell
239
+
240
+ -- Activate using bundle identifier (ensures Unity wakes up and starts processing)
241
+ tell application id bundleID to activate
242
+ '''
243
+ result = subprocess.run(
244
+ ["osascript", "-e", script],
245
+ capture_output=True,
246
+ text=True,
247
+ timeout=5,
248
+ )
249
+ if result.returncode != 0:
250
+ logger.debug(f"Failed to activate Unity PID {pid}: {result.stderr}")
251
+ return False
252
+ logger.info(f"Activated Unity instance with PID {pid} for project {unity_project_path}")
253
+ return True
254
+ else:
255
+ # No project path provided - activate any Unity process
256
+ return _focus_any_unity_macos()
257
+ else:
258
+ # For other apps, use direct activation
259
+ # Escape double quotes in app_name to prevent AppleScript injection
260
+ escaped_app_name = app_name.replace('"', '\\"')
261
+ result = subprocess.run(
262
+ ["osascript", "-e", f'tell application "{escaped_app_name}" to activate'],
263
+ capture_output=True,
264
+ text=True,
265
+ timeout=5,
266
+ )
267
+ return result.returncode == 0
67
268
  except Exception as e:
68
269
  logger.debug(f"Failed to focus app {app_name}: {e}")
69
270
  return False
70
271
 
71
272
 
273
+ def _focus_any_unity_macos() -> bool:
274
+ """Focus any Unity process on macOS (fallback when no project path specified)."""
275
+ try:
276
+ script = '''
277
+ tell application "System Events"
278
+ set unityProc to first process whose name contains "Unity"
279
+ set frontmost of unityProc to true
280
+ end tell
281
+ '''
282
+ result = subprocess.run(
283
+ ["osascript", "-e", script],
284
+ capture_output=True,
285
+ text=True,
286
+ timeout=5,
287
+ )
288
+ if result.returncode != 0:
289
+ logger.debug(f"Failed to activate Unity via System Events: {result.stderr}")
290
+ return False
291
+ return True
292
+ except Exception as e:
293
+ logger.debug(f"Failed to focus Unity: {e}")
294
+ return False
295
+
296
+
72
297
  def _get_frontmost_app_windows() -> str | None:
73
298
  """Get the title of the frontmost window on Windows."""
74
299
  try:
@@ -212,11 +437,17 @@ def _get_frontmost_app() -> str | None:
212
437
  return None
213
438
 
214
439
 
215
- def _focus_app(app_or_window: str) -> bool:
216
- """Focus an application/window (platform-specific)."""
440
+ def _focus_app(app_or_window: str, unity_project_path: str | None = None) -> bool:
441
+ """Focus an application/window (platform-specific).
442
+
443
+ Args:
444
+ app_or_window: Application name to focus
445
+ unity_project_path: For Unity apps on macOS, the full project root path for
446
+ multi-instance support
447
+ """
217
448
  system = platform.system()
218
449
  if system == "Darwin":
219
- return _focus_app_macos(app_or_window)
450
+ return _focus_app_macos(app_or_window, unity_project_path)
220
451
  elif system == "Windows":
221
452
  return _focus_app_windows(app_or_window)
222
453
  elif system == "Linux":
@@ -225,29 +456,46 @@ def _focus_app(app_or_window: str) -> bool:
225
456
 
226
457
 
227
458
  async def nudge_unity_focus(
228
- focus_duration_s: float = 0.5,
459
+ focus_duration_s: float | None = None,
229
460
  force: bool = False,
461
+ unity_project_path: str | None = None,
230
462
  ) -> bool:
231
463
  """
232
464
  Temporarily focus Unity to allow it to process, then return focus.
233
465
 
466
+ Uses exponential backoff for both interval and duration:
467
+ - Interval: 1s, 2s, 4s, 8s, 10s (time between nudges)
468
+ - Duration: 3s, 5s, 8s, 12s (how long Unity stays focused)
469
+ Resets on progress.
470
+
234
471
  Args:
235
- focus_duration_s: How long to keep Unity focused (seconds)
472
+ focus_duration_s: How long to keep Unity focused (seconds).
473
+ If None, uses exponential backoff (3s/5s/8s/12s based on consecutive nudges).
474
+ Can be overridden with UNITY_MCP_NUDGE_DURATION_S env var.
236
475
  force: If True, ignore the minimum interval between nudges
476
+ unity_project_path: Full path to Unity project root for multi-instance support.
477
+ e.g., "/Users/name/project" (NOT "/Users/name/project/Assets")
478
+ If None, targets any Unity process.
237
479
 
238
480
  Returns:
239
481
  True if nudge was performed, False if skipped or failed
240
482
  """
241
- global _last_nudge_time
483
+ if focus_duration_s is None:
484
+ # Use exponential backoff for focus duration
485
+ focus_duration_s = _get_current_focus_duration()
486
+ if focus_duration_s <= 0:
487
+ focus_duration_s = _DEFAULT_FOCUS_DURATION_S
488
+ global _last_nudge_time, _consecutive_nudges
242
489
 
243
490
  if not _is_available():
244
491
  logger.debug("Focus nudging not available on this platform")
245
492
  return False
246
493
 
247
- # Rate limit nudges
494
+ # Rate limit nudges using exponential backoff
248
495
  now = time.monotonic()
249
- if not force and (now - _last_nudge_time) < _MIN_NUDGE_INTERVAL_S:
250
- logger.info("Skipping nudge - too soon since last nudge")
496
+ current_interval = _get_current_nudge_interval()
497
+ if not force and (now - _last_nudge_time) < current_interval:
498
+ logger.debug(f"Skipping nudge - too soon since last nudge (interval: {current_interval:.1f}s)")
251
499
  return False
252
500
 
253
501
  # Get current frontmost app
@@ -261,21 +509,35 @@ async def nudge_unity_focus(
261
509
  logger.debug("Unity already focused, no nudge needed")
262
510
  return False
263
511
 
264
- logger.info(f"Nudging Unity focus (will return to {original_app})")
265
- _last_nudge_time = now
512
+ project_info = f" for {unity_project_path}" if unity_project_path else ""
513
+ logger.info(f"Nudging Unity focus{project_info} (interval: {current_interval:.1f}s, consecutive: {_consecutive_nudges}, duration: {focus_duration_s:.1f}s, will return to {original_app})")
266
514
 
267
- # Focus Unity
268
- if not _focus_app("Unity"):
269
- logger.warning("Failed to focus Unity")
515
+ # Focus Unity (with optional project path for multi-instance support)
516
+ if not _focus_app("Unity", unity_project_path):
517
+ logger.warning(f"Failed to focus Unity{project_info}")
270
518
  return False
271
519
 
272
- # Wait for Unity to process
520
+ # Wait for window switch animation to complete before starting timer
521
+ # macOS activate is asynchronous, so Unity might not be visible yet
522
+ await asyncio.sleep(0.5)
523
+
524
+ # Verify Unity is actually focused now
525
+ current_app = _get_frontmost_app()
526
+ if current_app and "Unity" not in current_app:
527
+ logger.warning(f"Unity activation didn't complete - current app is {current_app}")
528
+ # Continue anyway in case Unity is processing in background
529
+
530
+ # Only update state after successful activation attempt
531
+ _last_nudge_time = now
532
+ _consecutive_nudges += 1
533
+
534
+ # Wait for Unity to process (actual working time)
273
535
  await asyncio.sleep(focus_duration_s)
274
536
 
275
537
  # Return focus to original app
276
538
  if original_app and original_app != "Unity":
277
539
  if _focus_app(original_app):
278
- logger.info(f"Returned focus to {original_app}")
540
+ logger.info(f"Returned focus to {original_app} after {focus_duration_s:.1f}s Unity focus")
279
541
  else:
280
542
  logger.warning(f"Failed to return focus to {original_app}")
281
543
 
@@ -287,17 +549,23 @@ def should_nudge(
287
549
  editor_is_focused: bool,
288
550
  last_update_unix_ms: int | None,
289
551
  current_time_ms: int | None = None,
290
- stall_threshold_ms: int = 10_000,
552
+ stall_threshold_ms: int = 3_000,
291
553
  ) -> bool:
292
554
  """
293
555
  Determine if we should nudge Unity based on test job state.
294
556
 
557
+ Works with exponential backoff in nudge_unity_focus():
558
+ - First nudge happens after 3s of no progress
559
+ - Subsequent nudges use exponential backoff (1s, 2s, 4s, 8s, 10s max)
560
+ - Backoff resets when progress is detected (call reset_nudge_backoff())
561
+
295
562
  Args:
296
563
  status: Job status ("running", "succeeded", "failed")
297
564
  editor_is_focused: Whether Unity reports being focused
298
565
  last_update_unix_ms: Last time the job was updated (Unix ms)
299
566
  current_time_ms: Current time (Unix ms), or None to use current time
300
567
  stall_threshold_ms: How long without updates before considering it stalled
568
+ (default 3s for quick stall detection with exponential backoff)
301
569
 
302
570
  Returns:
303
571
  True if conditions suggest a nudge would help
@@ -1,101 +0,0 @@
1
- main.py,sha256=EoHA0upWjtQzuoOgN5BfNmGL6bIVnFQjRW5PHO5EmjY,29265
2
- cli/__init__.py,sha256=f2HjXqR9d8Uhibru211t9HPpdrb_1vdDC2v_NwF_eqA,63
3
- cli/main.py,sha256=V_VFa8tA-CDHNv9J5NzNSLxRuEGjRVZWDe4xn6rYdog,8457
4
- cli/commands/__init__.py,sha256=xQHf6o0afDV2HsU9gwSxjcrzS41cMCSGZyWYWxblPIk,69
5
- cli/commands/animation.py,sha256=emBE5oKhFQNU8V2ENm9E5N4Grj0Tah9H0X7fF6grQdk,2442
6
- cli/commands/asset.py,sha256=V1xzLgBPhdRzXsnj9Wt2HnJYo_8hT3RqoVnR2WrLP5w,7988
7
- cli/commands/audio.py,sha256=qJ-Whc8aH7oUgT79O_RRRo-lAVktFqtC5pgbyG2bRNo,3333
8
- cli/commands/batch.py,sha256=rMe8BDsthZ0AwaDrFoj6Kxl4xAVNRIlKSCcJ5eSagyY,5732
9
- cli/commands/code.py,sha256=FGV8IDx6eFhcEmc6jREQwHwoOdiUyhY8d6Hy7KN4cTw,5624
10
- cli/commands/component.py,sha256=uIOtno1T2mPF3rnW2OymetggScqtWrs_Th06FI7FISQ,6327
11
- cli/commands/editor.py,sha256=oM1g8DNoJZ6slOSfNJYbLN02rQVmIX_a2QLhecc_Qog,14586
12
- cli/commands/gameobject.py,sha256=b7ZxHXyIgUOvjYhHmKavigs-wfxGB6NhDMqqRyEGtNY,13643
13
- cli/commands/instance.py,sha256=J6uQrNIEWbnJT-Y09ICTA9R11lgtPQflBbmTrBr5bg8,3041
14
- cli/commands/lighting.py,sha256=eBvSDhQ5jkoUJJ4sito0yFxXwJv0JlpT4iD-D6Q2Pak,3869
15
- cli/commands/material.py,sha256=51uxeoTgqnnMuUQUbhBTdMdI70kU4pOCH6GUIy2OjQI,7847
16
- cli/commands/prefab.py,sha256=E6aWXKyosJH0pJPK8krsRYUrZhHjnCm3iUpIAy4dkes,8177
17
- cli/commands/scene.py,sha256=P08rud-6FZaO8Tw9jnP0xcS043Bf5IAooGbEDZPVBqw,6274
18
- cli/commands/script.py,sha256=Yf9o00irn4wf0cbsE665mxJehwtiIr0y3IHKLyvYhgY,6434
19
- cli/commands/shader.py,sha256=CwIIgyrU9OosVmidD6E9Txmn6Yyo4rDJBubrBchAlVw,6380
20
- cli/commands/texture.py,sha256=qkvxb94W2B4oqyCi0WI0Cvwvvch5sp-UenBH5xMHnNY,18251
21
- cli/commands/tool.py,sha256=9JQSUNPinLoDfP1T-STjcrn9A_UdIbGBr_c5G7X4r7k,1754
22
- cli/commands/ui.py,sha256=JDfAXE3ba45r41Svfop-fiy4p8C0gxE4ekJ8aFRG7aI,7627
23
- cli/commands/vfx.py,sha256=tmHdaGDUABJ339Ia2Y4MTqr72UnoUOf_LxY69qUnAPg,16373
24
- cli/utils/__init__.py,sha256=Gbm9hYC7UqwloFwdirXgo6z1iBktR9Y96o3bQcrYudc,613
25
- cli/utils/config.py,sha256=_k3XAFmXG22sv8tYIb5JmO46kNl3T1sGqFptySAayfc,1550
26
- cli/utils/connection.py,sha256=T9xmjfil0TAYJg5ZAbeqTtnmIhv5angQNG5vw40Ines,7619
27
- cli/utils/output.py,sha256=96daU55ta_hl7UeOhNh5Iy7OJ4psbdR9Nfx1-q2k3xA,6370
28
- cli/utils/suggestions.py,sha256=n6KG3Mrvub28X9rPFYFLRTtZ6HePp3PhhAeojG2WOJw,929
29
- core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
- core/config.py,sha256=czkTtNji1crQcQbUvmdx4OL7f-RBqkVhj_PtHh-w7rs,1623
31
- core/logging_decorator.py,sha256=D9CD7rFvQz-MBG-G4inizQj0Ivr6dfc9RBmTrw7q8mI,1383
32
- core/telemetry.py,sha256=eHjYgzd8f7eTwSwF2Kbi8D4TtJIcdaDjKLeo1c-0hVA,19829
33
- core/telemetry_decorator.py,sha256=ycSTrzVNCDQHSd-xmIWOpVfKFURPxpiZe_XkOQAGDAo,6705
34
- mcpforunityserver-9.3.0b20260128055651.dist-info/licenses/LICENSE,sha256=bv5lDJZQEqxBgjjc1rkRbkEwpSIHF-8N-1Od0VnEJFw,1066
35
- models/__init__.py,sha256=JlscZkGWE9TRmSoBi99v_LSl8OAFNGmr8463PYkXin4,179
36
- models/models.py,sha256=heXuvdBtdats1SGwW8wKFFHM0qR4hA6A7qETn5s9BZ0,1827
37
- models/unity_response.py,sha256=oJ1PTsnNc5VBC-9OgM59C0C-R9N-GdmEdmz_yph4GSU,1454
38
- services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
39
- services/custom_tool_service.py,sha256=WJxljL-hdJE5GMlAhVimHVhQwwnWHCd0StgWhWEFgaI,18592
40
- services/registry/__init__.py,sha256=QCwcYThvGF0kBt3WR6DBskdyxkegJC7NymEChgJA-YM,470
41
- services/registry/resource_registry.py,sha256=T_Kznqgvt5kKgV7mU85nb0LlFuB4rg-Tm4Cjhxt-IcI,1467
42
- services/registry/tool_registry.py,sha256=9tMwOP07JE92QFYUS4KvoysO0qC9pkBD5B79kjRsSPw,1304
43
- services/resources/__init__.py,sha256=G8uSEYJtiyX3yg0QsfoeGdDXOdbU89l5m0B5Anay1Fc,3054
44
- services/resources/active_tool.py,sha256=zDuWRK1uz853TrMNv0w8vhZVOxemDPoI4QAkXSIezN8,1480
45
- services/resources/custom_tools.py,sha256=3t0mKAL9PkJbv8S4DpRFU8D-NlRWkCd2geO6QnlQo7I,1716
46
- services/resources/editor_state.py,sha256=pQdcsWGcKV7-6icpcVXtFD35CHUXodANc0jXkljVdLs,10823
47
- services/resources/gameobject.py,sha256=RM28kfsV208zdTy-549U2_nwSPiAHYo6SqXy22k4tC8,9116
48
- services/resources/layers.py,sha256=wE-mSgZsknGrXKu-0Cppv6NeijszD7beFf88dizT0ZI,1086
49
- services/resources/menu_items.py,sha256=9SNycjwTXoeS1ZHra0Y1fTyCjSEdPCo34JyxtuqauG8,1021
50
- services/resources/prefab.py,sha256=z5mTWNke5MQauovbgdNa3HRe5-5B6K7FBspp-OtfGHM,7303
51
- services/resources/prefab_stage.py,sha256=RyVskG-P9lb4szbsTDhPpyDMb0ptLskr0BnoYJylhw0,1388
52
- services/resources/project_info.py,sha256=ggiUj9rJUvIddxorKu9yqJiHTWOnxyywkjjsKXhIyqA,1329
53
- services/resources/selection.py,sha256=MALwKkM9xsKing2bALNVTVLWzDTE_b26EVbnVUGZivU,1845
54
- services/resources/tags.py,sha256=IKZWiZhBO_HkJqFXqBvWeIcMxhGN_QXkonzuAEFsEfg,1055
55
- services/resources/tests.py,sha256=xDvvgesPSU93nLD_ERQopOpkpq69pbMEqmFsJd0jekI,2063
56
- services/resources/unity_instances.py,sha256=XRR5YCDe8v_FXG45VlSdEPaqu7Qlbnm4NYIRzK5brjc,4354
57
- services/resources/windows.py,sha256=FyzPEtEmfKiXYh1lviemZ7-bFyjkAR61_seSTXQA9rk,1433
58
- services/state/external_changes_scanner.py,sha256=ZiXu8ZcK5B-hv7CaJLmnEIa9JxzgOBpdmrsRDY2eK5I,9052
59
- services/tools/__init__.py,sha256=mS9EpbPWchYj6gNW1eu0REv-SLPsQkY8xTkk7u-DeMU,2607
60
- services/tools/batch_execute.py,sha256=hjh67kgWvQDHyGd2N-Tfezv9WAj5x_pWTt_Vybmmq7s,3501
61
- services/tools/debug_request_context.py,sha256=Duq5xiuSmRO5GdvWAlZhCfOfmrwvK7gGkRC4wYnXmXk,2907
62
- services/tools/execute_custom_tool.py,sha256=hiZbm2A9t84f92jitzvkE2G4CMOIUiDVm7u5B8K-RbU,1527
63
- services/tools/execute_menu_item.py,sha256=k4J89LlXmEGyo9z3NK8Q0vREIzr11ucF_9tN_JeQq9M,1248
64
- services/tools/find_gameobjects.py,sha256=Qpfd_oQG0fluz8S1CfriGh1FmLnZ080-ZEZOrAsij8U,3602
65
- services/tools/find_in_file.py,sha256=SxhMeo8lRrt0OiGApGZSFUnq671bxVfK8qgAsHxLua8,6493
66
- services/tools/manage_asset.py,sha256=St_iWQWg9icztnRthU78t6JNhJN0AlC6ELiZhn-SNZU,5990
67
- services/tools/manage_components.py,sha256=2_nKPk9iPAf5VyYiXuRxSkN8U76VNQbMtE68UTPngrw,5061
68
- services/tools/manage_editor.py,sha256=ShvlSBQRfoNQ0DvqBWak_Hi3MB7tv2WkMKEhrKQipk0,3279
69
- services/tools/manage_gameobject.py,sha256=AXHT4fcrxvsaX53bypKoz3egY2uFI5kf7JCn2SizfB4,16604
70
- services/tools/manage_material.py,sha256=Zt-tqGRCmOKTmttsu5yeudFNWzkDBkeuf44av06g-w0,5548
71
- services/tools/manage_prefabs.py,sha256=mGGuYYpB2b9OV0fxNOtI8WnTZj9KjF7A3Isdzx8GGuI,6973
72
- services/tools/manage_scene.py,sha256=-ARtRuj7ZNk_14lmMSORnQs0qTAYKBTPtUfk0sNDo6A,5370
73
- services/tools/manage_script.py,sha256=MzPw0xXjtbdjEyjvUfLem9fa3GVE-WGvCr4WEVfW9Cs,28461
74
- services/tools/manage_scriptable_object.py,sha256=tezG_mbGzPLNpL3F7l5JJLyyjJN3rJi1thGMU8cpOC4,3659
75
- services/tools/manage_shader.py,sha256=bucRKzQww7opy6DK5nf6isVaEECWWqJ-DVkFulp8CV8,3185
76
- services/tools/manage_texture.py,sha256=ap2WolIJw2iVnLyAHhY6WahiGNLmtejJX7k0kq1zWrc,25932
77
- services/tools/manage_vfx.py,sha256=7KFbRohF8EzaD0m7vVIEwjUz-QwC7NEXS5cVcU6Die0,4710
78
- services/tools/preflight.py,sha256=0nvo0BmZMdIGop1Ha_vypkjn2VLiRvskF0uxh_SlZgE,4162
79
- services/tools/read_console.py,sha256=ps23debJcQkj3Ap-MqTYVhopYnKGspJs9QHLJHZAAkE,6826
80
- services/tools/refresh_unity.py,sha256=KrRA8bmLkDLFO1XBv2NmagQAp1dmyaXdUAap567Hcv4,7100
81
- services/tools/run_tests.py,sha256=wg8Ke8vpKHxyz0kqFaJC5feXTL3e6Cxzi0QKNitLDRE,9176
82
- services/tools/script_apply_edits.py,sha256=0f-SaP5NUYGuivl4CWHjR8F-CXUpt3-5qkHpf_edn1U,47677
83
- services/tools/set_active_instance.py,sha256=pdmC1SxFijyzzjeEyC2N1bXk-GNMu_iXsbCieIpa-R4,4242
84
- services/tools/utils.py,sha256=uk--6w_-O0eVAxczackXbgKde2ONmsgci43G3wY7dfA,4258
85
- transport/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
86
- transport/models.py,sha256=6wp7wsmSaeeJEvUGXPF1m6zuJnxJ1NJlCC4YZ9oQIq0,1226
87
- transport/plugin_hub.py,sha256=X6tAnJU0s1LQtgIgiK_YHBhSWMRD5bRjbkGjOl8eLFQ,23725
88
- transport/plugin_registry.py,sha256=nW-7O7PN0QUgSWivZTkpAVKKq9ZOe2b2yeIdpaNt_3I,4359
89
- transport/unity_instance_middleware.py,sha256=DD8gs-peMRmRJz9CYwaHEh4m75LTYPDjVuKuw9sArBw,10438
90
- transport/unity_transport.py,sha256=G6aMC1qR31YZOBZs4fxQbSQBHuXBP1d5Qn0MJaB3yGs,3908
91
- transport/legacy/port_discovery.py,sha256=JDSCqXLodfTT7fOsE0DFC1jJ3QsU6hVaYQb7x7FgdxY,12728
92
- transport/legacy/stdio_port_registry.py,sha256=j4iARuP6wetppNDG8qKeuvo1bJKcSlgEhZvSyl_uf0A,2313
93
- transport/legacy/unity_connection.py,sha256=FE9ZQfYMhHvIxBycr_DjI3BKvuEdORXuABnCE5Q2tjQ,36733
94
- utils/focus_nudge.py,sha256=HaTOSI7wzDmdRviodUHx2oQFPIL_jSwubai3YkDJbH0,9910
95
- utils/module_discovery.py,sha256=My48ofB1BUqxiBoAZAGbEaLQYdsrDhMm8MayBP_bUSQ,2005
96
- utils/reload_sentinel.py,sha256=s1xMWhl-r2XwN0OUbiUv_VGUy8TvLtV5bkql-5n2DT0,373
97
- mcpforunityserver-9.3.0b20260128055651.dist-info/METADATA,sha256=oYHDprvEMpsJnYVleQbe99kXV7CRCOoPY32jTDkZ7Jc,5804
98
- mcpforunityserver-9.3.0b20260128055651.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
99
- mcpforunityserver-9.3.0b20260128055651.dist-info/entry_points.txt,sha256=pPm70RXQvkt3uBhPOtViDa47ZTA03RaQ6rwXvyi8oiI,70
100
- mcpforunityserver-9.3.0b20260128055651.dist-info/top_level.txt,sha256=3-A65WsmBO6UZYH8O5mINdyhhZ63SDssr8LncRd1PSQ,46
101
- mcpforunityserver-9.3.0b20260128055651.dist-info/RECORD,,
utils/reload_sentinel.py DELETED
@@ -1,9 +0,0 @@
1
- """
2
- Deprecated: Sentinel flipping is handled inside Unity via the MCP menu
3
- 'MCP/Flip Reload Sentinel'. This module remains only as a compatibility shim.
4
- All functions are no-ops to prevent accidental external writes.
5
- """
6
-
7
-
8
- def flip_reload_sentinel(*args, **kwargs) -> str:
9
- return "reload_sentinel.py is deprecated; use execute_menu_item → 'MCP/Flip Reload Sentinel'"