settled-computer 0.1.0a1__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.
- settled_computer/__init__.py +39 -0
- settled_computer/engine.py +581 -0
- settled_computer/server.py +754 -0
- settled_computer-0.1.0a1.dist-info/METADATA +284 -0
- settled_computer-0.1.0a1.dist-info/RECORD +9 -0
- settled_computer-0.1.0a1.dist-info/WHEEL +5 -0
- settled_computer-0.1.0a1.dist-info/entry_points.txt +2 -0
- settled_computer-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- settled_computer-0.1.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
settled_computer.server - local (stdio) MCP server for desktop computer use with
|
|
4
|
+
event-driven settling.
|
|
5
|
+
|
|
6
|
+
Every action tool (click, type_text, press_key, scroll, drag, mouse_move) performs the action,
|
|
7
|
+
then waits until the screen reacts and stops changing (see settle.py), and returns the settled
|
|
8
|
+
screenshot plus a one-line note ("Screen settled 0.42s after the action" / "No visible change..."
|
|
9
|
+
/ "Still changing after 8s"). No fixed sleeps, and no separate screenshot round trip.
|
|
10
|
+
|
|
11
|
+
The `act` tool runs a whole sequence (click -> type -> key) inside ONE tool call, validates every
|
|
12
|
+
step before touching the desktop, and stops at the first real anomaly, so the agent model spends
|
|
13
|
+
one turn on what used to be three.
|
|
14
|
+
|
|
15
|
+
Design rules worth knowing
|
|
16
|
+
--------------------------
|
|
17
|
+
* Tools are serialized with one lock. Hosts may issue parallel tool calls; without the lock their
|
|
18
|
+
settle windows overlap and each verdict is contaminated by the other action.
|
|
19
|
+
* Input (mouse/keyboard) runs on one dedicated worker thread, so a 3 s drag or a long paste never
|
|
20
|
+
blocks the event loop.
|
|
21
|
+
* screenshot() ALWAYS returns an image. Action results may omit the image ("no image = unchanged")
|
|
22
|
+
only while the last image the model received is younger than SETTLE_MCP_ELIDE_TTL seconds: the
|
|
23
|
+
server cannot know what is still in the model's context (new chat, pruned images, compaction).
|
|
24
|
+
* A region that keeps animating (video, ticker) is auto-ignored after two consecutive bails, for
|
|
25
|
+
SETTLE_MCP_AUTO_IGNORE_SECS seconds, then re-checked, so it expires once the motion stops.
|
|
26
|
+
* Values set with configure() are pinned: adaptive learning and per-action defaults never override them.
|
|
27
|
+
|
|
28
|
+
Install
|
|
29
|
+
-------
|
|
30
|
+
pip install "mcp[cli]" mss pyautogui pillow numpy # + pyperclip for non-ASCII typing
|
|
31
|
+
pip install opencv-python # optional: ~3x faster JPEG encode
|
|
32
|
+
sudo apt install python3-tk # Linux only: pyautogui exits without it
|
|
33
|
+
python settle_mcp.py --check # verify capture + coordinates
|
|
34
|
+
|
|
35
|
+
Register (Claude Desktop: claude_desktop_config.json / Claude Code: `claude mcp add`)
|
|
36
|
+
-------------------------------------------------------------------------------------
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"settled-computer": {
|
|
40
|
+
"command": "python",
|
|
41
|
+
"args": ["/absolute/path/to/settle_mcp.py"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
claude mcp add settled-computer -- python /absolute/path/to/settle_mcp.py
|
|
46
|
+
|
|
47
|
+
Keep settle.py in the same folder. Coordinates are in the pixels of the image the tools return.
|
|
48
|
+
|
|
49
|
+
Environment variables
|
|
50
|
+
---------------------
|
|
51
|
+
SETTLE_MCP_MONITOR monitor index (mss numbering, 1 = primary) default 1
|
|
52
|
+
SETTLE_MCP_MAX_WIDTH max width of images returned to the model in px default 1280
|
|
53
|
+
SETTLE_MCP_QUALITY JPEG quality of returned images default 70
|
|
54
|
+
SETTLE_MCP_FAILSAFE 0 disables pyautogui's fail-safe default 1
|
|
55
|
+
SETTLE_MCP_ELIDE_TTL seconds an unchanged screen may omit its image default 45 (0 = never omit)
|
|
56
|
+
SETTLE_MCP_AUTO_IGNORE_SECS seconds an auto-detected animating region is default 30 (0 = off)
|
|
57
|
+
ignored before it is re-checked
|
|
58
|
+
|
|
59
|
+
SAFETY: this server lets a model move your mouse and type on your real desktop. Keep the
|
|
60
|
+
pyautogui fail-safe on (slam the mouse into the top-left corner to abort all actions), and
|
|
61
|
+
prefer running it inside a VM or a dedicated user session. macOS needs Screen Recording and
|
|
62
|
+
Accessibility permission for the app hosting this process; Linux needs an X11 session
|
|
63
|
+
(Wayland blocks capture and synthetic input).
|
|
64
|
+
|
|
65
|
+
Never print to stdout in this file: stdout is the MCP transport. Logs go to stderr.
|
|
66
|
+
"""
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import argparse
|
|
70
|
+
import asyncio
|
|
71
|
+
import functools
|
|
72
|
+
import io
|
|
73
|
+
import os
|
|
74
|
+
import platform
|
|
75
|
+
import sys
|
|
76
|
+
import time
|
|
77
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
78
|
+
from dataclasses import replace
|
|
79
|
+
from typing import Literal, Optional, Sequence
|
|
80
|
+
|
|
81
|
+
try: # pydantic requires typing_extensions.TypedDict on Python < 3.12
|
|
82
|
+
from typing_extensions import TypedDict
|
|
83
|
+
except ImportError:
|
|
84
|
+
from typing import TypedDict
|
|
85
|
+
|
|
86
|
+
import numpy as np
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
import mss
|
|
90
|
+
import pyautogui
|
|
91
|
+
from PIL import Image as PILImage
|
|
92
|
+
except (Exception, SystemExit) as exc: # missing dependency, or no display (pyautogui exits without tkinter on Linux)
|
|
93
|
+
sys.stderr.write(
|
|
94
|
+
f"settle_mcp: cannot start ({exc!r}).\n"
|
|
95
|
+
"Install: pip install 'mcp[cli]' mss pyautogui pillow numpy (Linux also needs: sudo apt install python3-tk)\n"
|
|
96
|
+
"and run inside a desktop session.\n"
|
|
97
|
+
)
|
|
98
|
+
raise SystemExit(1)
|
|
99
|
+
|
|
100
|
+
try: # optional, ~3x faster than PIL for BGRA -> resized JPEG
|
|
101
|
+
import cv2
|
|
102
|
+
except ImportError:
|
|
103
|
+
cv2 = None
|
|
104
|
+
|
|
105
|
+
from pydantic import ConfigDict
|
|
106
|
+
|
|
107
|
+
try: # mcp >= 2.0 renamed FastMCP to MCPServer
|
|
108
|
+
from mcp.server.mcpserver import Image, MCPServer as _Server
|
|
109
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
110
|
+
except ImportError: # mcp 1.x
|
|
111
|
+
from mcp.server.fastmcp import FastMCP as _Server, Image
|
|
112
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
113
|
+
|
|
114
|
+
from .engine import LatencyBook, SettleConfig, SettleResult, _as_u32, act_and_settle, wait_settled
|
|
115
|
+
|
|
116
|
+
MONITOR = int(os.environ.get("SETTLE_MCP_MONITOR", "1"))
|
|
117
|
+
MAX_WIDTH = int(os.environ.get("SETTLE_MCP_MAX_WIDTH", "1280"))
|
|
118
|
+
JPEG_QUALITY = int(os.environ.get("SETTLE_MCP_QUALITY", "70"))
|
|
119
|
+
ELIDE_TTL = float(os.environ.get("SETTLE_MCP_ELIDE_TTL", "45"))
|
|
120
|
+
AUTO_IGNORE_SECS = float(os.environ.get("SETTLE_MCP_AUTO_IGNORE_SECS", "30"))
|
|
121
|
+
|
|
122
|
+
pyautogui.PAUSE = 0.0 # pyautogui's default 0.1s pause after every call is itself a fixed sleep
|
|
123
|
+
pyautogui.FAILSAFE = os.environ.get("SETTLE_MCP_FAILSAFE", "1") != "0"
|
|
124
|
+
|
|
125
|
+
_IS_MAC = platform.system() == "Darwin"
|
|
126
|
+
|
|
127
|
+
# Per-action defaults. They apply only to fields the user has NOT pinned through configure().
|
|
128
|
+
_KIND_DEFAULTS = {
|
|
129
|
+
"type": {"react_deadline": 0.2, "quiet_time": 0.12},
|
|
130
|
+
"scroll": {"quiet_time": 0.15},
|
|
131
|
+
"hover": {"react_deadline": 0.2},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def log(msg: str) -> None:
|
|
136
|
+
sys.stderr.write(f"settle_mcp: {msg}\n")
|
|
137
|
+
sys.stderr.flush()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --------------------------------------------------------------------------- tool allowlist
|
|
141
|
+
def _tool_enabled(name: str) -> bool:
|
|
142
|
+
"""SETTLE_MCP_TOOLS=a,b,c allows only the named tools. Unset = all enabled.
|
|
143
|
+
lets an operator strip input-injection (e.g. `SETTLE_MCP_TOOLS=screenshot,screen_info,wait`
|
|
144
|
+
for a look-but-don't-touch server) — disabled tools fail with a clear error."""
|
|
145
|
+
raw = os.environ.get("SETTLE_MCP_TOOLS")
|
|
146
|
+
if not raw:
|
|
147
|
+
return True
|
|
148
|
+
return name in {t.strip() for t in raw.split(",") if t.strip()}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def gated(name: str):
|
|
152
|
+
"""Decorator for mutating tools: refuse the call when the allowlist excludes it."""
|
|
153
|
+
import functools
|
|
154
|
+
|
|
155
|
+
def deco(fn):
|
|
156
|
+
@functools.wraps(fn)
|
|
157
|
+
async def wrapper(*args, **kwargs):
|
|
158
|
+
if not _tool_enabled(name):
|
|
159
|
+
raise ToolError(
|
|
160
|
+
f"tool {name!r} is disabled on this server by the SETTLE_MCP_TOOLS allowlist")
|
|
161
|
+
return await fn(*args, **kwargs)
|
|
162
|
+
return wrapper
|
|
163
|
+
return deco
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# --------------------------------------------------------------------------- serialization
|
|
167
|
+
_LOCK: Optional[asyncio.Lock] = None
|
|
168
|
+
_INPUT_POOL = ThreadPoolExecutor(max_workers=1, thread_name_prefix="settle-input") # one stable input thread
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def serialized(fn):
|
|
172
|
+
"""Run tool calls one at a time. GUI actions are inherently sequential: overlapping settle windows
|
|
173
|
+
would attribute one action's effects to the other and interleave inputs."""
|
|
174
|
+
@functools.wraps(fn)
|
|
175
|
+
async def wrapper(*args, **kwargs):
|
|
176
|
+
global _LOCK
|
|
177
|
+
if _LOCK is None:
|
|
178
|
+
_LOCK = asyncio.Lock()
|
|
179
|
+
async with _LOCK:
|
|
180
|
+
return await fn(*args, **kwargs)
|
|
181
|
+
return wrapper
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _run_input(fn) -> None:
|
|
185
|
+
"""Execute blocking mouse/keyboard code off the event loop, on the dedicated input thread."""
|
|
186
|
+
loop = asyncio.get_running_loop()
|
|
187
|
+
try:
|
|
188
|
+
await loop.run_in_executor(_INPUT_POOL, fn)
|
|
189
|
+
except pyautogui.FailSafeException:
|
|
190
|
+
raise ToolError("pyautogui fail-safe triggered: the pointer is in a screen corner. "
|
|
191
|
+
"Move it away and retry (or set SETTLE_MCP_FAILSAFE=0).") from None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- capture + coordinates
|
|
195
|
+
def _make_grabber(monitor_index: int):
|
|
196
|
+
"""mss capture bound to one monitor. Frames are BGRA uint8. Must be created and used on the
|
|
197
|
+
same thread (mss requirement on Windows), so it is created lazily inside the event loop."""
|
|
198
|
+
sct = (getattr(mss, "MSS", None) or mss.mss)() # mss.mss is deprecated in newer releases
|
|
199
|
+
mon = sct.monitors[monitor_index]
|
|
200
|
+
|
|
201
|
+
def grab() -> np.ndarray:
|
|
202
|
+
return np.asarray(sct.grab(mon)) # contiguous BGRA: enables settle.py's fast compare
|
|
203
|
+
|
|
204
|
+
return grab, mon
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _same_frame(a: Optional[np.ndarray], b: Optional[np.ndarray],
|
|
208
|
+
ignore_regions: Sequence[tuple] = ()) -> bool:
|
|
209
|
+
"""Equal frames, optionally ignoring regions (screen fractions), e.g. a video that is always
|
|
210
|
+
animating and must not defeat the 'no image = unchanged' elision. Compares packed uint32
|
|
211
|
+
pixels and masks the difference instead of copying 8 MB frames."""
|
|
212
|
+
if a is None or b is None or a.shape != b.shape:
|
|
213
|
+
return False
|
|
214
|
+
ne = _as_u32(a) != _as_u32(b)
|
|
215
|
+
if not ne.any():
|
|
216
|
+
return True
|
|
217
|
+
h, w = ne.shape
|
|
218
|
+
for x0, y0, x1, y1 in ignore_regions:
|
|
219
|
+
ne[max(0, int(y0 * h) - 8):min(h, int(np.ceil(y1 * h)) + 8),
|
|
220
|
+
max(0, int(x0 * w) - 8):min(w, int(np.ceil(x1 * w)) + 8)] = False
|
|
221
|
+
return not ne.any()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _overlap(a: tuple, b: tuple) -> float:
|
|
225
|
+
"""Intersection area divided by the smaller box's area (0..1)."""
|
|
226
|
+
ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))
|
|
227
|
+
iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
|
|
228
|
+
smaller = min((a[2] - a[0]) * (a[3] - a[1]), (b[2] - b[0]) * (b[3] - b[1]))
|
|
229
|
+
return (ix * iy) / smaller if smaller > 0 else 0.0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class State:
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
self.grab = None
|
|
235
|
+
self.mon: dict = {}
|
|
236
|
+
self.native_w = self.native_h = 0
|
|
237
|
+
self.img_w = self.img_h = 0
|
|
238
|
+
self.base = SettleConfig()
|
|
239
|
+
self.pinned: set = set() # fields set via configure(): never overridden
|
|
240
|
+
self.book = LatencyBook()
|
|
241
|
+
self.last_frame: Optional[np.ndarray] = None
|
|
242
|
+
self.last_sent: Optional[np.ndarray] = None # last frame the model actually received
|
|
243
|
+
self.last_sent_at = 0.0
|
|
244
|
+
self.auto_regions: dict = {} # box -> monotonic time it was (re)confirmed animating
|
|
245
|
+
self.pending_residual: Optional[tuple] = None
|
|
246
|
+
|
|
247
|
+
def ensure(self) -> None:
|
|
248
|
+
if self.grab is not None:
|
|
249
|
+
return
|
|
250
|
+
self.grab, self.mon = _make_grabber(MONITOR)
|
|
251
|
+
frame = self.grab()
|
|
252
|
+
self.native_h, self.native_w = frame.shape[:2]
|
|
253
|
+
scale = min(1.0, MAX_WIDTH / self.native_w)
|
|
254
|
+
self.img_w = max(1, round(self.native_w * scale))
|
|
255
|
+
self.img_h = max(1, round(self.native_h * scale))
|
|
256
|
+
self.last_frame = frame
|
|
257
|
+
log(f"monitor {MONITOR}: {self.mon}; native {self.native_w}x{self.native_h}; "
|
|
258
|
+
f"images {self.img_w}x{self.img_h}; jpeg encoder "
|
|
259
|
+
f"{'cv2' if cv2 is not None else 'PIL'}")
|
|
260
|
+
|
|
261
|
+
def to_point(self, x: float, y: float) -> tuple[int, int]:
|
|
262
|
+
"""Image pixel -> global pyautogui coordinate (handles downscaling, HiDPI, monitor offset)."""
|
|
263
|
+
self.ensure() # image size is unknown until the first capture
|
|
264
|
+
if not (0 <= x < self.img_w and 0 <= y < self.img_h):
|
|
265
|
+
raise ToolError(f"({x}, {y}) is outside the {self.img_w}x{self.img_h} screenshot; "
|
|
266
|
+
"coordinates are pixels of the screenshot image")
|
|
267
|
+
px = self.mon["left"] + x * self.mon["width"] / self.img_w
|
|
268
|
+
py = self.mon["top"] + y * self.mon["height"] / self.img_h
|
|
269
|
+
return round(px), round(py)
|
|
270
|
+
|
|
271
|
+
# ---- what the model has seen
|
|
272
|
+
def mark_sent(self, frame: np.ndarray) -> None:
|
|
273
|
+
self.last_sent = frame
|
|
274
|
+
self.last_sent_at = time.monotonic()
|
|
275
|
+
|
|
276
|
+
def can_elide(self) -> bool:
|
|
277
|
+
return (ELIDE_TTL > 0 and self.last_sent is not None
|
|
278
|
+
and time.monotonic() - self.last_sent_at <= ELIDE_TTL)
|
|
279
|
+
|
|
280
|
+
# ---- regions we do not wait on
|
|
281
|
+
def effective_ignore(self) -> tuple:
|
|
282
|
+
now = time.monotonic()
|
|
283
|
+
for box in [b for b, t in self.auto_regions.items() if now - t > 600]:
|
|
284
|
+
del self.auto_regions[box] # forgotten entirely after 10 minutes
|
|
285
|
+
active = [b for b, t in self.auto_regions.items() if now - t < AUTO_IGNORE_SECS]
|
|
286
|
+
return tuple(self.base.ignore_regions) + tuple(active)
|
|
287
|
+
|
|
288
|
+
def learn_residual(self, box: tuple) -> bool:
|
|
289
|
+
"""Called on every 'residual' bail. Returns True when the region is (re)activated for auto-ignore:
|
|
290
|
+
immediately if it was confirmed before, otherwise on the second consecutive bail."""
|
|
291
|
+
if AUTO_IGNORE_SECS <= 0:
|
|
292
|
+
return False
|
|
293
|
+
now = time.monotonic()
|
|
294
|
+
pad = 0.01
|
|
295
|
+
padded = (max(0.0, box[0] - pad), max(0.0, box[1] - pad), min(1.0, box[2] + pad), min(1.0, box[3] + pad))
|
|
296
|
+
for known in list(self.auto_regions):
|
|
297
|
+
if _overlap(known, padded) > 0.5:
|
|
298
|
+
self.auto_regions[known] = now
|
|
299
|
+
return True
|
|
300
|
+
if self.pending_residual is not None and _overlap(self.pending_residual, padded) > 0.5:
|
|
301
|
+
self.auto_regions[padded] = now
|
|
302
|
+
self.pending_residual = None
|
|
303
|
+
return True
|
|
304
|
+
self.pending_residual = padded
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
# ---- encoding
|
|
308
|
+
def _encode_sync(self, frame: np.ndarray) -> bytes:
|
|
309
|
+
if cv2 is not None and frame.ndim == 3 and frame.shape[2] == 4:
|
|
310
|
+
bgr = cv2.cvtColor(frame, cv2.COLOR_BGRA2BGR)
|
|
311
|
+
if bgr.shape[1] != self.img_w or bgr.shape[0] != self.img_h:
|
|
312
|
+
bgr = cv2.resize(bgr, (self.img_w, self.img_h), interpolation=cv2.INTER_AREA)
|
|
313
|
+
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), JPEG_QUALITY])
|
|
314
|
+
if ok:
|
|
315
|
+
return enc.tobytes()
|
|
316
|
+
img = PILImage.fromarray(np.ascontiguousarray(frame[:, :, 2::-1])) # BGRA -> RGB
|
|
317
|
+
if img.size != (self.img_w, self.img_h):
|
|
318
|
+
img = img.resize((self.img_w, self.img_h), PILImage.BILINEAR)
|
|
319
|
+
buf = io.BytesIO()
|
|
320
|
+
img.save(buf, "JPEG", quality=JPEG_QUALITY)
|
|
321
|
+
return buf.getvalue()
|
|
322
|
+
|
|
323
|
+
async def encode(self, frame: np.ndarray) -> Image:
|
|
324
|
+
data = await asyncio.to_thread(self._encode_sync, frame) # keep the event loop free
|
|
325
|
+
return Image(data=data, format="jpeg")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
state = State()
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _coords_note() -> str:
|
|
332
|
+
return f"Coordinates are pixels of this image ({state.img_w}x{state.img_h})."
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def _settle_for(key: str, action) -> SettleResult:
|
|
336
|
+
"""Run one blocking input action on the input thread, wait for the UI to settle, record the sample.
|
|
337
|
+
Config precedence: configure()-pinned values > per-action defaults > adaptive learning."""
|
|
338
|
+
state.ensure()
|
|
339
|
+
cfg = state.book.config_for(key, state.base, pinned=frozenset(state.pinned))
|
|
340
|
+
defaults = {k: v for k, v in _KIND_DEFAULTS.get(key, {}).items() if k not in state.pinned}
|
|
341
|
+
cfg = replace(cfg, ignore_regions=state.effective_ignore(), **defaults)
|
|
342
|
+
res = await act_and_settle(state.grab, lambda: _run_input(action), cfg)
|
|
343
|
+
state.book.record(key, res)
|
|
344
|
+
state.last_frame = res.frame
|
|
345
|
+
return res
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _residual_advice(box: tuple) -> str:
|
|
349
|
+
if state.learn_residual(box):
|
|
350
|
+
return (f" Auto-ignoring this region for the next {AUTO_IGNORE_SECS:.0f}s so later actions do not wait "
|
|
351
|
+
"on it (it is re-checked afterwards).")
|
|
352
|
+
return (" If the moving region is irrelevant (video, ticker), call configure(ignore_regions="
|
|
353
|
+
f"[[{box[0]:.2f},{box[1]:.2f},{box[2]:.2f},{box[3]:.2f}]]) to stop waiting on it.")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def _finish(res: SettleResult, kind: str = "action") -> list:
|
|
357
|
+
"""Turn a SettleResult into the tool return. When nothing new is visible AND the model received an
|
|
358
|
+
image recently (ELIDE_TTL), the image is omitted to save vision tokens; otherwise it is attached."""
|
|
359
|
+
note = res.note_for_model(kind)
|
|
360
|
+
frame = res.frame
|
|
361
|
+
if res.reason == "residual" and res.motion_box:
|
|
362
|
+
note += _residual_advice(res.motion_box)
|
|
363
|
+
if res.reason in ("no_reaction", "residual") and state.can_elide():
|
|
364
|
+
ignore = list(state.effective_ignore())
|
|
365
|
+
if res.reason == "residual" and res.motion_box:
|
|
366
|
+
ignore.append(res.motion_box)
|
|
367
|
+
if _same_frame(frame, state.last_sent, ignore):
|
|
368
|
+
age = time.monotonic() - state.last_sent_at
|
|
369
|
+
where = "Outside the moving region the" if res.reason == "residual" else "The"
|
|
370
|
+
return [f"{note} {where} screen is unchanged since the last image you received "
|
|
371
|
+
f"({age:.0f}s ago), so no new image is attached. Call screenshot() if you are unsure."]
|
|
372
|
+
img = await state.encode(frame)
|
|
373
|
+
state.mark_sent(frame)
|
|
374
|
+
return [img, f"{note} {_coords_note()}"]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# --------------------------------------------------------------------------- key handling
|
|
378
|
+
_KEY_ALIASES = {
|
|
379
|
+
"cmd": "command" if _IS_MAC else "win", "super": "command" if _IS_MAC else "win",
|
|
380
|
+
"meta": "command" if _IS_MAC else "win", "windows": "win", "control": "ctrl",
|
|
381
|
+
"return": "enter", "esc": "escape", "del": "delete", "pgup": "pageup", "pgdn": "pagedown",
|
|
382
|
+
"option": "option" if _IS_MAC else "alt", "alt": "option" if _IS_MAC else "alt",
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _normalize_keys(spec: str) -> list:
|
|
387
|
+
parts = [p for p in spec.lower().replace(" ", "").split("+") if p]
|
|
388
|
+
keys = [_KEY_ALIASES.get(p, p) for p in parts]
|
|
389
|
+
bad = [k for k in keys if k not in pyautogui.KEYBOARD_KEYS]
|
|
390
|
+
if not keys or bad:
|
|
391
|
+
raise ToolError(f"unknown key(s) {bad or spec!r}; examples: 'enter', 'ctrl+s', 'alt+tab', 'shift+f10'")
|
|
392
|
+
return keys
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _press(combo: list) -> None:
|
|
396
|
+
pyautogui.press(combo[0]) if len(combo) == 1 else pyautogui.hotkey(*combo)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _type(text: str) -> None:
|
|
400
|
+
if text.isascii():
|
|
401
|
+
pyautogui.write(text, interval=0)
|
|
402
|
+
return
|
|
403
|
+
try:
|
|
404
|
+
import pyperclip
|
|
405
|
+
except ImportError as exc:
|
|
406
|
+
raise ToolError("non-ASCII text needs `pip install pyperclip` (it pastes via the clipboard)") from exc
|
|
407
|
+
pyperclip.copy(text) # overwrites the clipboard
|
|
408
|
+
pyautogui.hotkey("command" if _IS_MAC else "ctrl", "v")
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _scroll(direction: str, amount: int, target: Optional[tuple]) -> None:
|
|
412
|
+
if target:
|
|
413
|
+
pyautogui.moveTo(*target)
|
|
414
|
+
if direction in ("up", "down"):
|
|
415
|
+
pyautogui.scroll(amount if direction == "up" else -amount)
|
|
416
|
+
else:
|
|
417
|
+
pyautogui.hscroll(amount if direction == "right" else -amount)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --------------------------------------------------------------------------- MCP tools
|
|
421
|
+
class ActStep(TypedDict, total=False):
|
|
422
|
+
__pydantic_config__ = ConfigDict(extra="forbid") # a misspelled key is an error that names it
|
|
423
|
+
click: list[float] # [x, y] in screenshot pixels
|
|
424
|
+
dblclick: list[float]
|
|
425
|
+
rightclick: list[float]
|
|
426
|
+
move: list[float]
|
|
427
|
+
type: str
|
|
428
|
+
key: str
|
|
429
|
+
scroll: list # ["up"|"down"|"left"|"right", amount, optional x, optional y]
|
|
430
|
+
wait: float # seconds
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
_INSTRUCTIONS = (
|
|
434
|
+
"Desktop control with event-driven settling: every action waits until the screen reacts and "
|
|
435
|
+
"stops changing, then returns the settled screenshot plus a short note.\n"
|
|
436
|
+
"Rules that save you turns:\n"
|
|
437
|
+
"- Do not call screenshot() right after an action - the action's returned image IS the "
|
|
438
|
+
"after-state. Use screenshot() when you have no recent frame; it always returns an image.\n"
|
|
439
|
+
"- Prefer the `act` tool for sequences of known actions (e.g. click a field, type, press "
|
|
440
|
+
"enter): one call, one final image. Every step is validated before anything runs.\n"
|
|
441
|
+
"- An action response with NO image means the screen is unchanged since the last image you "
|
|
442
|
+
"received a few seconds ago; if you no longer have that image, call screenshot().\n"
|
|
443
|
+
"- 'No visible change' does not mean failure: focusing an already-focused field or setting a "
|
|
444
|
+
"state that is already set changes nothing. Judge by the image, not only the note.\n"
|
|
445
|
+
"- Follow the notes' explicit suggestions (e.g. configure ignore_regions for a video region)."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
mcp = _Server("settled-computer", instructions=_INSTRUCTIONS)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@mcp.tool()
|
|
452
|
+
@serialized
|
|
453
|
+
async def screenshot():
|
|
454
|
+
"""Look at the current screen (waits until it is visually stable). ALWAYS returns an image. Every action
|
|
455
|
+
already returns the settled screen, so use this only when you have no recent frame or after wait()."""
|
|
456
|
+
state.ensure()
|
|
457
|
+
cfg = replace(state.base, react_deadline=0.0, ignore_regions=state.effective_ignore())
|
|
458
|
+
res = await wait_settled(state.grab, cfg)
|
|
459
|
+
state.last_frame = res.frame
|
|
460
|
+
if res.reason == "timeout":
|
|
461
|
+
note = f"Screen was still changing after {res.waited:.1f}s (loading or animating); call wait()."
|
|
462
|
+
elif res.reason == "residual" and res.motion_box:
|
|
463
|
+
b = res.motion_box
|
|
464
|
+
note = (f"Screen is stable except a region [{b[0]:.2f},{b[1]:.2f},{b[2]:.2f},{b[3]:.2f}] that keeps "
|
|
465
|
+
f"animating (bailed after {res.waited:.1f}s). Everything outside it is stable; if that region "
|
|
466
|
+
"is what you are waiting on, call wait()." + _residual_advice(b))
|
|
467
|
+
else:
|
|
468
|
+
note = "Screen is stable."
|
|
469
|
+
img = await state.encode(res.frame)
|
|
470
|
+
state.mark_sent(res.frame)
|
|
471
|
+
return [img, f"{note} {_coords_note()}"]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
@mcp.tool()
|
|
475
|
+
@gated('click')
|
|
476
|
+
@serialized
|
|
477
|
+
async def click(x: float, y: float, button: Literal["left", "right", "middle"] = "left", clicks: int = 1):
|
|
478
|
+
"""Click at (x, y) in screenshot pixels. clicks=2 double-clicks. Returns the settled screen."""
|
|
479
|
+
px, py = state.to_point(x, y)
|
|
480
|
+
n = max(1, min(clicks, 3))
|
|
481
|
+
return await _finish(await _settle_for("click", lambda: pyautogui.click(px, py, clicks=n, button=button)),
|
|
482
|
+
"click")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@mcp.tool()
|
|
486
|
+
@gated('type_text')
|
|
487
|
+
@serialized
|
|
488
|
+
async def type_text(text: str):
|
|
489
|
+
"""Type text into the focused control. Newlines press Enter. Returns the settled screen."""
|
|
490
|
+
return await _finish(await _settle_for("type", lambda: _type(text)), "type")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@mcp.tool()
|
|
494
|
+
@gated('press_key')
|
|
495
|
+
@serialized
|
|
496
|
+
async def press_key(keys: str):
|
|
497
|
+
"""Press a key or shortcut, e.g. 'enter', 'tab', 'ctrl+s', 'alt+tab', 'cmd+space'. Returns the settled screen."""
|
|
498
|
+
combo = _normalize_keys(keys)
|
|
499
|
+
return await _finish(await _settle_for("key", lambda: _press(combo)), "key")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@mcp.tool()
|
|
503
|
+
@gated('scroll')
|
|
504
|
+
@serialized
|
|
505
|
+
async def scroll(direction: Literal["up", "down", "left", "right"], amount: int = 5,
|
|
506
|
+
x: Optional[float] = None, y: Optional[float] = None):
|
|
507
|
+
"""Scroll by `amount` wheel clicks, optionally at (x, y) in screenshot pixels. Returns the settled screen."""
|
|
508
|
+
target = state.to_point(x, y) if x is not None and y is not None else None
|
|
509
|
+
amount = max(1, min(amount, 50))
|
|
510
|
+
return await _finish(await _settle_for("scroll", lambda: _scroll(direction, amount, target)), "scroll")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@mcp.tool()
|
|
514
|
+
@gated('drag')
|
|
515
|
+
@serialized
|
|
516
|
+
async def drag(x1: float, y1: float, x2: float, y2: float, duration: float = 0.3):
|
|
517
|
+
"""Left-button drag from (x1, y1) to (x2, y2) in screenshot pixels. Returns the settled screen."""
|
|
518
|
+
a, b = state.to_point(x1, y1), state.to_point(x2, y2)
|
|
519
|
+
secs = max(0.05, min(duration, 3.0))
|
|
520
|
+
|
|
521
|
+
def action() -> None:
|
|
522
|
+
pyautogui.moveTo(*a)
|
|
523
|
+
pyautogui.dragTo(*b, duration=secs, button="left")
|
|
524
|
+
|
|
525
|
+
return await _finish(await _settle_for("drag", action), "drag")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@mcp.tool()
|
|
529
|
+
@gated('mouse_move')
|
|
530
|
+
@serialized
|
|
531
|
+
async def mouse_move(x: float, y: float):
|
|
532
|
+
"""Move the pointer to (x, y) to reveal hover menus or tooltips. Returns the settled screen.
|
|
533
|
+
Not a way to check anything - it returns the screen like every other action does."""
|
|
534
|
+
px, py = state.to_point(x, y)
|
|
535
|
+
return await _finish(await _settle_for("hover", lambda: pyautogui.moveTo(px, py)), "hover")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---- act(): validate everything first, then run
|
|
539
|
+
_STOP_KINDS = {"click", "dblclick", "rightclick", "key"} # a no-op here usually means a mis-click
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _note_kind(kind: str) -> str:
|
|
543
|
+
return {"wait": "wait", "move": "hover", "type": "type"}.get(kind, "action")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _plan_step(i: int, kind: str, arg):
|
|
547
|
+
"""Validate one act() step WITHOUT touching the desktop; return an async callable that performs it
|
|
548
|
+
and settles. Validating every step up front means a typo in step 5 cannot leave 1-4 half-executed."""
|
|
549
|
+
def xy(a) -> tuple:
|
|
550
|
+
try:
|
|
551
|
+
x, y = float(a[0]), float(a[1])
|
|
552
|
+
except (TypeError, ValueError, IndexError, KeyError):
|
|
553
|
+
raise ToolError(f"step {i + 1}: {kind} expects [x, y] in screenshot pixels, got {a!r}") from None
|
|
554
|
+
return state.to_point(x, y)
|
|
555
|
+
|
|
556
|
+
if kind in ("click", "dblclick", "rightclick"):
|
|
557
|
+
px, py = xy(arg)
|
|
558
|
+
clicks, button = (2, "left") if kind == "dblclick" else (1, "right" if kind == "rightclick" else "left")
|
|
559
|
+
return lambda: _settle_for("click", lambda: pyautogui.click(px, py, clicks=clicks, button=button))
|
|
560
|
+
if kind == "move":
|
|
561
|
+
px, py = xy(arg)
|
|
562
|
+
return lambda: _settle_for("hover", lambda: pyautogui.moveTo(px, py))
|
|
563
|
+
if kind == "type":
|
|
564
|
+
text = str(arg)
|
|
565
|
+
return lambda: _settle_for("type", lambda: _type(text))
|
|
566
|
+
if kind == "key":
|
|
567
|
+
combo = _normalize_keys(str(arg))
|
|
568
|
+
return lambda: _settle_for("key", lambda: _press(combo))
|
|
569
|
+
if kind == "scroll":
|
|
570
|
+
if not isinstance(arg, (list, tuple)) or not arg:
|
|
571
|
+
raise ToolError(f'step {i + 1}: scroll expects ["up"|"down"|"left"|"right", amount, optional x, y]')
|
|
572
|
+
direction = arg[0]
|
|
573
|
+
if direction not in ("up", "down", "left", "right"):
|
|
574
|
+
raise ToolError(f"step {i + 1}: bad scroll direction {direction!r}")
|
|
575
|
+
try:
|
|
576
|
+
amount = max(1, min(int(arg[1]) if len(arg) > 1 else 5, 50))
|
|
577
|
+
except (TypeError, ValueError):
|
|
578
|
+
raise ToolError(f"step {i + 1}: scroll amount must be an integer, got {arg[1]!r}") from None
|
|
579
|
+
target = xy(arg[2:4]) if len(arg) >= 4 else None
|
|
580
|
+
return lambda: _settle_for("scroll", lambda: _scroll(direction, amount, target))
|
|
581
|
+
if kind == "wait":
|
|
582
|
+
try:
|
|
583
|
+
seconds = max(0.1, min(float(arg), 30.0))
|
|
584
|
+
except (TypeError, ValueError):
|
|
585
|
+
raise ToolError(f"step {i + 1}: wait expects a number of seconds, got {arg!r}") from None
|
|
586
|
+
|
|
587
|
+
async def run() -> SettleResult:
|
|
588
|
+
cfg = replace(state.base, react_deadline=seconds, max_wait=seconds + 5.0,
|
|
589
|
+
ignore_regions=state.effective_ignore())
|
|
590
|
+
res = await wait_settled(state.grab, cfg, baseline=state.last_frame)
|
|
591
|
+
state.last_frame = res.frame
|
|
592
|
+
return res
|
|
593
|
+
return run
|
|
594
|
+
raise ToolError(f"step {i + 1}: unknown kind {kind!r}; valid: click, dblclick, rightclick, "
|
|
595
|
+
"move, type, key, scroll, wait")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _should_stop(res: SettleResult, kind: str, policy: str) -> bool:
|
|
599
|
+
if res.reason == "timeout":
|
|
600
|
+
return True
|
|
601
|
+
if res.reason == "no_reaction":
|
|
602
|
+
return policy == "always" or (policy == "auto" and kind in _STOP_KINDS)
|
|
603
|
+
return False # settled, or a residual (confined animation is not a failure)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@mcp.tool()
|
|
607
|
+
@gated('act')
|
|
608
|
+
@serialized
|
|
609
|
+
async def act(steps: list[ActStep], screenshot: Literal["final", "none"] = "final",
|
|
610
|
+
stop_on_no_reaction: Literal["auto", "always", "never"] = "auto"):
|
|
611
|
+
"""Run a sequence of up to 8 actions in ONE call, each settling before the next. Steps are single-key
|
|
612
|
+
objects: {"click":[x,y]}, {"dblclick":[x,y]}, {"rightclick":[x,y]}, {"type":"text"}, {"key":"enter"},
|
|
613
|
+
{"scroll":["down",5]} (optionally ["down",5,x,y]), {"move":[x,y]}, {"wait":1.0}.
|
|
614
|
+
Every step is validated before anything runs. A timeout always stops the sequence. 'No visible change'
|
|
615
|
+
stops it only for click/dblclick/rightclick/key under stop_on_no_reaction="auto" (a click that does nothing
|
|
616
|
+
is usually a mis-click); use "never" when a no-op is expected (e.g. the field is already focused) or
|
|
617
|
+
"always" to stop on any no-op. Pass screenshot="none" to skip the final image when it completed cleanly."""
|
|
618
|
+
state.ensure()
|
|
619
|
+
if not isinstance(steps, list) or not (1 <= len(steps) <= 8):
|
|
620
|
+
raise ToolError("steps must be a list of 1-8 single-key action objects, "
|
|
621
|
+
'e.g. [{"click":[640,400]}, {"type":"hello"}, {"key":"enter"}]')
|
|
622
|
+
plan = []
|
|
623
|
+
for i, step in enumerate(steps):
|
|
624
|
+
if not isinstance(step, dict) or len(step) != 1:
|
|
625
|
+
raise ToolError(f"step {i + 1}: expected a single-key object like "
|
|
626
|
+
'{"click":[640,400]}, got ' + repr(step))
|
|
627
|
+
kind, arg = next(iter(step.items()))
|
|
628
|
+
plan.append((kind, _plan_step(i, kind, arg)))
|
|
629
|
+
|
|
630
|
+
done: list[str] = []
|
|
631
|
+
quiet: list[str] = [] # steps that produced no visible change but did not stop the sequence
|
|
632
|
+
res: Optional[SettleResult] = None
|
|
633
|
+
for i, (kind, run) in enumerate(plan):
|
|
634
|
+
res = await run()
|
|
635
|
+
if _should_stop(res, kind, stop_on_no_reaction):
|
|
636
|
+
img = await state.encode(res.frame)
|
|
637
|
+
state.mark_sent(res.frame)
|
|
638
|
+
earlier = f" Done before it: {', '.join(done)}." if done else ""
|
|
639
|
+
remaining = ", ".join(f"{j + 1}:{plan[j][0]}" for j in range(i + 1, len(plan)))
|
|
640
|
+
later = f" Not run: {remaining}." if remaining else ""
|
|
641
|
+
hint = (" If no change was expected here (e.g. the field was already focused), re-run the remaining "
|
|
642
|
+
'steps with stop_on_no_reaction="never".') if res.reason == "no_reaction" else ""
|
|
643
|
+
return [img, f"STOPPED at step {i + 1} ({kind}): {res.note_for_model(_note_kind(kind))}"
|
|
644
|
+
f"{earlier}{later}{hint} {_coords_note()}"]
|
|
645
|
+
if res.reason == "no_reaction":
|
|
646
|
+
quiet.append(f"{i + 1}:{kind}")
|
|
647
|
+
done.append(f"{i + 1}:{kind}")
|
|
648
|
+
|
|
649
|
+
summary = f"All {len(plan)} steps completed ({', '.join(done)})."
|
|
650
|
+
if quiet:
|
|
651
|
+
summary += f" No visible change from: {', '.join(quiet)}."
|
|
652
|
+
if res is not None and res.reason == "residual" and res.motion_box:
|
|
653
|
+
summary += " A confined region is still animating; the rest of the screen is stable." + _residual_advice(res.motion_box)
|
|
654
|
+
if screenshot == "none" and res is not None and res.reason in ("settled", "residual"):
|
|
655
|
+
return [summary + ' No image attached (screenshot="none").']
|
|
656
|
+
img = await state.encode(res.frame)
|
|
657
|
+
state.mark_sent(res.frame)
|
|
658
|
+
return [img, f"{summary} {_coords_note()}"]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@mcp.tool()
|
|
662
|
+
@serialized
|
|
663
|
+
async def wait(seconds: float = 3.0):
|
|
664
|
+
"""Wait up to `seconds` for something to change (e.g. a slow page or a long operation), then wait
|
|
665
|
+
for it to settle. Use after a 'still changing'/'timeout' note or when a known slow operation is running."""
|
|
666
|
+
state.ensure()
|
|
667
|
+
seconds = max(0.1, min(seconds, 60.0))
|
|
668
|
+
cfg = replace(state.base, react_deadline=seconds, max_wait=seconds + 5.0,
|
|
669
|
+
ignore_regions=state.effective_ignore())
|
|
670
|
+
res = await wait_settled(state.grab, cfg, baseline=state.last_frame)
|
|
671
|
+
state.last_frame = res.frame
|
|
672
|
+
if res.reason == "no_reaction":
|
|
673
|
+
note = res.note_for_model("wait")
|
|
674
|
+
elif res.reason == "timeout":
|
|
675
|
+
note = f"Still changing after {res.waited:.1f}s."
|
|
676
|
+
else:
|
|
677
|
+
note = f"Screen changed and settled after {res.waited:.1f}s."
|
|
678
|
+
img = await state.encode(res.frame)
|
|
679
|
+
state.mark_sent(res.frame)
|
|
680
|
+
return [img, f"{note} {_coords_note()}"]
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@mcp.tool()
|
|
684
|
+
@serialized
|
|
685
|
+
async def configure(quiet_time: Optional[float] = None, react_deadline: Optional[float] = None,
|
|
686
|
+
max_wait: Optional[float] = None, ignore_regions: Optional[list[list[float]]] = None,
|
|
687
|
+
residual_bail_after: Optional[float] = None) -> str:
|
|
688
|
+
"""Tune settling. quiet_time: seconds of stillness that count as settled (0.05-5). react_deadline: how long
|
|
689
|
+
to wait for any change after an action (0-10). max_wait: hard cap per action (0.5-60). ignore_regions: list of
|
|
690
|
+
[x0, y0, x1, y1] screen fractions (0-1) to mask out, e.g. a clock or video; pass [] to clear (this also clears
|
|
691
|
+
auto-detected regions). residual_bail_after: return early when only a confined region keeps animating (0.5-10).
|
|
692
|
+
Values set here are pinned: adaptive learning and per-action defaults never override them."""
|
|
693
|
+
cfg = state.base
|
|
694
|
+
if quiet_time is not None:
|
|
695
|
+
cfg = replace(cfg, quiet_time=min(max(quiet_time, 0.05), 5.0))
|
|
696
|
+
state.pinned.add("quiet_time")
|
|
697
|
+
if react_deadline is not None:
|
|
698
|
+
cfg = replace(cfg, react_deadline=min(max(react_deadline, 0.0), 10.0))
|
|
699
|
+
state.pinned.add("react_deadline")
|
|
700
|
+
if max_wait is not None:
|
|
701
|
+
cfg = replace(cfg, max_wait=min(max(max_wait, 0.5), 60.0))
|
|
702
|
+
state.pinned.add("max_wait")
|
|
703
|
+
if residual_bail_after is not None:
|
|
704
|
+
cfg = replace(cfg, residual_bail_after=min(max(residual_bail_after, 0.5), 10.0))
|
|
705
|
+
if ignore_regions is not None:
|
|
706
|
+
for r in ignore_regions:
|
|
707
|
+
if len(r) != 4 or not all(0.0 <= v <= 1.0 for v in r) or r[0] >= r[2] or r[1] >= r[3]:
|
|
708
|
+
raise ToolError(f"bad region {r!r}: expected [x0, y0, x1, y1] fractions with x0<x1, y0<y1")
|
|
709
|
+
cfg = replace(cfg, ignore_regions=tuple(tuple(r) for r in ignore_regions))
|
|
710
|
+
if not ignore_regions:
|
|
711
|
+
state.auto_regions.clear()
|
|
712
|
+
state.pending_residual = None
|
|
713
|
+
state.base = cfg
|
|
714
|
+
return (f"quiet_time={cfg.quiet_time}s react_deadline={cfg.react_deadline}s max_wait={cfg.max_wait}s "
|
|
715
|
+
f"residual_bail_after={cfg.residual_bail_after}s "
|
|
716
|
+
f"ignore_regions={[list(r) for r in cfg.ignore_regions]} pinned={sorted(state.pinned)}")
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
@mcp.tool()
|
|
720
|
+
@serialized
|
|
721
|
+
async def screen_info() -> str:
|
|
722
|
+
"""Describe the capture: image size the tools use, monitor geometry, platform."""
|
|
723
|
+
state.ensure()
|
|
724
|
+
auto = [[round(v, 2) for v in b] for b in state.effective_ignore()[len(state.base.ignore_regions):]]
|
|
725
|
+
return (f"Images are {state.img_w}x{state.img_h}px (native {state.native_w}x{state.native_h}); "
|
|
726
|
+
f"monitor {MONITOR} geometry {state.mon}; platform {platform.system()}; "
|
|
727
|
+
f"auto-ignored regions {auto}.")
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
# --------------------------------------------------------------------------- entry point
|
|
731
|
+
def _check() -> None:
|
|
732
|
+
grab, mon = _make_grabber(MONITOR)
|
|
733
|
+
grab() # warm up
|
|
734
|
+
t0 = time.perf_counter()
|
|
735
|
+
frame = grab()
|
|
736
|
+
ms = (time.perf_counter() - t0) * 1000
|
|
737
|
+
print(f"monitor {MONITOR}: {mon}")
|
|
738
|
+
print(f"frame {frame.shape[1]}x{frame.shape[0]}, grab {ms:.0f} ms, pyautogui size {tuple(pyautogui.size())}")
|
|
739
|
+
print(f"pointer now at {tuple(pyautogui.position())}; fail-safe {'on' if pyautogui.FAILSAFE else 'OFF'}")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def main() -> None:
|
|
743
|
+
"""Console-script entry point (`settled-computer`)."""
|
|
744
|
+
ap = argparse.ArgumentParser(description="Settled computer-use MCP server (stdio)")
|
|
745
|
+
ap.add_argument("--check", action="store_true", help="test capture and coordinates, then exit")
|
|
746
|
+
args = ap.parse_args()
|
|
747
|
+
if args.check:
|
|
748
|
+
_check()
|
|
749
|
+
else:
|
|
750
|
+
mcp.run() # stdio transport
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
if __name__ == "__main__":
|
|
754
|
+
main()
|