Skip to content

Commit 95bf1e1

Browse files
Richardson GundeRichardson Gunde
authored andcommitted
feat: add macOS Agent Space manager — isolate agent windows to Space 2 [#29]
1 parent be74513 commit 95bf1e1

2 files changed

Lines changed: 497 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""macOS Agent Space manager — isolate agent windows to Space 2.
2+
3+
Subscribes to ``NSWorkspaceActiveSpaceDidChangeNotification`` so the manager
4+
is notified whenever the user switches Spaces. When :py:meth:`move_to_agent_space`
5+
is called, the named application is moved to Space 2 via AppleScript, keeping
6+
the user's Space 1 free of agent windows.
7+
8+
AppleScript approach (``key code 18 using {control down}``) is used instead of
9+
the private ``CGSMoveWindowsToManagedSpace`` API so the module has no private-
10+
framework dependency.
11+
12+
Usage::
13+
14+
if sys.platform == "darwin":
15+
manager = AgentSpaceManager()
16+
manager.activate()
17+
manager.move_to_agent_space("Safari")
18+
...
19+
manager.deactivate()
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
import subprocess
26+
import sys
27+
import threading
28+
from typing import Callable
29+
30+
if sys.platform != "darwin":
31+
raise ImportError("operator_use.computer.macos.agent_space is macOS-only")
32+
33+
logger = logging.getLogger(__name__)
34+
35+
# AppleScript that:
36+
# 1. Switches to Space 2 via Ctrl+2
37+
# 2. Activates the target application (bringing its windows to the current Space)
38+
# 3. Returns to Space 1 via Ctrl+1
39+
_MOVE_TO_SPACE2_SCRIPT = """\
40+
tell application "System Events"
41+
key code 19 using {{control down}}
42+
delay 0.4
43+
end tell
44+
tell application "{app_name}"
45+
activate
46+
end tell
47+
delay 0.3
48+
tell application "System Events"
49+
key code 18 using {{control down}}
50+
delay 0.2
51+
end tell
52+
"""
53+
54+
# AppleScript used when *not* returning to Space 1 (agent stays on Space 2).
55+
_SWITCH_TO_SPACE2_SCRIPT = """\
56+
tell application "System Events"
57+
key code 19 using {{control down}}
58+
end tell
59+
delay 0.4
60+
tell application "{app_name}"
61+
activate
62+
end tell
63+
"""
64+
65+
66+
class AgentSpaceManager:
67+
"""Manage a dedicated macOS Space (Space 2) for agent-opened windows.
68+
69+
Args:
70+
on_space_change: Optional callback invoked each time
71+
``NSWorkspaceActiveSpaceDidChangeNotification`` fires. Receives no
72+
arguments; called on the notification thread.
73+
return_to_user_space: When ``True`` (default), after moving an app to
74+
Space 2 the manager switches back to Space 1 so the user's view is
75+
undisturbed.
76+
"""
77+
78+
def __init__(
79+
self,
80+
on_space_change: Callable[[], None] | None = None,
81+
return_to_user_space: bool = True,
82+
) -> None:
83+
self._on_space_change = on_space_change
84+
self._return_to_user_space = return_to_user_space
85+
self._active = False
86+
self._observer = None # NSObject observer token
87+
self._lock = threading.Lock()
88+
89+
# ------------------------------------------------------------------
90+
# Lifecycle
91+
# ------------------------------------------------------------------
92+
93+
def activate(self) -> None:
94+
"""Subscribe to Space-change notifications.
95+
96+
Safe to call multiple times; subsequent calls are no-ops.
97+
"""
98+
with self._lock:
99+
if self._active:
100+
return
101+
self._active = True
102+
103+
try:
104+
from AppKit import NSWorkspace # type: ignore[import]
105+
from Foundation import NSNotificationCenter as _NSNotificationCenter # type: ignore[import] # noqa: F401
106+
107+
workspace = NSWorkspace.sharedWorkspace()
108+
notification_center = workspace.notificationCenter()
109+
110+
self._observer = notification_center.addObserverForName_object_queue_usingBlock_(
111+
"NSWorkspaceActiveSpaceDidChangeNotification",
112+
None,
113+
None,
114+
self._handle_space_change,
115+
)
116+
logger.debug("AgentSpaceManager activated — subscribed to space-change notifications")
117+
except ImportError:
118+
logger.warning(
119+
"AgentSpaceManager: pyobjc-framework-Cocoa not available — "
120+
"space-change notifications disabled."
121+
)
122+
123+
def deactivate(self) -> None:
124+
"""Unsubscribe from Space-change notifications.
125+
126+
Safe to call multiple times; subsequent calls are no-ops.
127+
"""
128+
with self._lock:
129+
if not self._active:
130+
return
131+
self._active = False
132+
133+
if self._observer is not None:
134+
try:
135+
from AppKit import NSWorkspace # type: ignore[import]
136+
137+
workspace = NSWorkspace.sharedWorkspace()
138+
notification_center = workspace.notificationCenter()
139+
notification_center.removeObserver_(self._observer)
140+
self._observer = None
141+
except ImportError:
142+
pass
143+
except Exception:
144+
logger.debug("AgentSpaceManager: failed to remove observer", exc_info=True)
145+
146+
logger.debug("AgentSpaceManager deactivated")
147+
148+
# ------------------------------------------------------------------
149+
# Core operation
150+
# ------------------------------------------------------------------
151+
152+
def move_to_agent_space(self, app_name: str) -> bool:
153+
"""Move *app_name* to Space 2 using AppleScript.
154+
155+
Args:
156+
app_name: The display name of the application as it appears in the
157+
Dock / Activity Monitor (e.g. ``"Safari"``, ``"Terminal"``).
158+
159+
Returns:
160+
``True`` if the AppleScript ran without error, ``False`` otherwise.
161+
"""
162+
if self._return_to_user_space:
163+
script = _MOVE_TO_SPACE2_SCRIPT.format(app_name=app_name)
164+
else:
165+
script = _SWITCH_TO_SPACE2_SCRIPT.format(app_name=app_name)
166+
167+
return self._run_applescript(script, context=f"move {app_name!r} to Space 2")
168+
169+
# ------------------------------------------------------------------
170+
# Internal helpers
171+
# ------------------------------------------------------------------
172+
173+
def _handle_space_change(self, notification) -> None: # noqa: ANN001
174+
"""Invoked by NSNotificationCenter on space transitions."""
175+
logger.debug("AgentSpaceManager: active space changed")
176+
if self._on_space_change:
177+
try:
178+
self._on_space_change()
179+
except Exception:
180+
logger.exception("AgentSpaceManager on_space_change callback raised")
181+
182+
@staticmethod
183+
def _run_applescript(script: str, context: str = "") -> bool:
184+
"""Execute an AppleScript string via ``osascript`` and return success."""
185+
try:
186+
result = subprocess.run(
187+
["osascript", "-e", script],
188+
capture_output=True,
189+
text=True,
190+
timeout=10,
191+
)
192+
if result.returncode != 0:
193+
logger.warning(
194+
"AgentSpaceManager AppleScript failed [%s]: %s",
195+
context,
196+
result.stderr.strip(),
197+
)
198+
return False
199+
return True
200+
except FileNotFoundError:
201+
logger.error("AgentSpaceManager: osascript not found — is this macOS?")
202+
return False
203+
except subprocess.TimeoutExpired:
204+
logger.error("AgentSpaceManager: AppleScript timed out [%s]", context)
205+
return False
206+
except Exception:
207+
logger.exception(
208+
"AgentSpaceManager: unexpected error running AppleScript [%s]", context
209+
)
210+
return False

0 commit comments

Comments
 (0)